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

416 lines (360 loc) 17.4 kB
/** * Represents a Let’s Encrypt TLS certificate. * * @module * @copyright Copyright © 2020 Aral Balkan, Small Technology Foundation. * @license AGPLv3 or later. */ import fs from 'fs' import tls from 'tls' import util from 'util' import moment from 'moment' import log from './util/log.js' import { Certificate as X509Certificate } from './x.509/rfc5280.js' import Account from './Account.js' import AccountIdentity from './identities/AccountIdentity.js' import Directory from './Directory.js' import Order from './Order.js' import CertificateIdentity from './identities/CertificateIdentity.js' import AcmeRequest from './AcmeRequest.js' import Throws from './util/Throws.js' const throws = new Throws({ // No custom errors are thrown by this class. }) /** * Represents a Let’s Encrypt TLS certificate. * * @alias module:lib/Certificate * @param {String[]} domains List of domains this certificate covers. */ export default class Certificate { /** * Get a SecureContext that can be used in an SNICallback. * * @category async * @returns {Promise<tls.SecureContext>} A promise for a SecureContext that can be used in creating https servers. */ async getSecureContext () { if (!this.#secureContext) { if (this.#busyCreatingSecureContextForTheFirstTime) { return null } // We don’t have the secure context yet, create it. await this.createSecureContext() } return this.#secureContext } /** * Creates an instance of Certificate. * * @param {Configuration} configuration Configuration instance. */ constructor (configuration = throws.ifMissing()) { this.#configuration = configuration this.attemptToRecoverFromFailedRenewalAttemptIfNecessary() this.#domains = configuration.domains // If the certificate already exists, load and cache it. if (fs.existsSync(this.#configuration.certificatePath)) { this.pem = fs.readFileSync(this.#configuration.certificatePath, 'utf-8') this.identity = new CertificateIdentity(this.#configuration) log(' 📃 ❨auto-encrypt❩ Certificate exists, loaded it (and the corresponding private key) from disk.') this.startCheckingForRenewal(/* alsoCheckNow = */ true) } else { log(' 📃 ❨auto-encrypt❩ Certificate does not exist; will be provisioned on first hit of the server.') } } // // Private. // #configuration = null #account = null #accountIdentity = null #directory = null #secureContext = null #domains = null #renewalDate = null #checkForRenewalIntervalId = null #busyCreatingSecureContextForTheFirstTime = false #_pem = null #_identity = null #_key = null #_issuer = null #_subject = null #_alternativeNames = null #_serialNumber = null #_issueDate = null #_expiryDate = null get isProvisioned () { return this.#_pem !== null } get pem () { return this.#_pem } get identity () { return this.#_identity } get key () { return this.#_key } get serialNumber () { return this.#_serialNumber } get issuer () { return this.#_issuer } get subject () { return this.#_subject } get alternativeNames () { return this.#_alternativeNames } get issueDate () { return this.#_issueDate } get expiryDate () { return this.#_expiryDate } get renewalDate () { return this.#renewalDate } set pem (certificatePem) { this.#_pem = certificatePem const details = this.parseDetails(certificatePem) this.#_serialNumber = details.serialNumber this.#_issuer = details.issuer this.#_subject = details.subject this.#_alternativeNames = details.alternativeNames this.#_issueDate = moment(details.issuedAt) this.#_expiryDate = moment(details.expiresAt) // Display the certificate with a nice border :) const logMessagePrefix = ' ❨auto-encrypt❩ ' let logMessageBody = [ `Serial number : ${details.serialNumber}`, `Issuer : ${details.issuer}`, `Subject : ${details.subject}`, `Alternative names: ${details.alternativeNames.reduce((string, name) => `${string}, ${name}`)}`, `Issued on : ${this.issueDate.calendar().toLowerCase()} (${this.issueDate.fromNow()})`, `Expires on : ${this.expiryDate.calendar().toLowerCase()} (${this.expiryDate.fromNow()})` ] const longestLineLength = logMessageBody.reduce((accumulator, currentValue) => currentValue.length > accumulator ? currentValue.length : accumulator, 0) const horizontalBar = '─'.repeat(longestLineLength+2) // +2 is for the one-space padding at each side of a line. const topBorder = `${logMessagePrefix}╭${horizontalBar}╮` const bottomBorder = `${logMessagePrefix}╰${horizontalBar}╯` logMessageBody = logMessageBody.map(line => { return `${logMessagePrefix}│ ${line}${' '.repeat(longestLineLength - line.length)} │` }) log(` 🎀 ❨auto-encrypt❩ Certificate ready:\n${topBorder}\n${logMessageBody.join('\n')}\n${bottomBorder}`) } set identity (certificateIdentity) { this.#_identity = certificateIdentity this.#_key = certificateIdentity.privatePEM } set key (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'key', 'set via identity') } set serialNumber (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'serialNumber', 'set via pem') } set issuer (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'issuer', 'set via pem') } set subject (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'subject', 'set via pem') } set alternativeNames (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'alternativeNames', 'set via pem') } set issueDate (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'issueDate', 'set via pem') } set expiryDate (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'expiryDate', 'set via pem') } set renewalDate (value) { throws.error(Symbol.for('ReadOnlyAccessorError'), 'renewalDate', 'set internally') } /** * Check if certificate-identity.pem.old or certificate.pem.old files exist. * If they do, it means that something went wrong while certificate was trying to be * renewed. So restore them and use them and hopefully the next renewal attempt will * succeed or at least buy the administrator of the server some time to fix the issue. */ attemptToRecoverFromFailedRenewalAttemptIfNecessary () { const oldCertificateIdentityPath = `${this.#configuration.certificateIdentityPath}.old` const oldCertificatePath = `${this.#configuration.certificatePath}.old` const certificateIdentityPath = this.#configuration.certificateIdentityPath const certificatePath = this.#configuration.certificatePath if (fs.existsSync(oldCertificateIdentityPath) && fs.existsSync(oldCertificatePath)) { log(' 🚑 ❨auto-encrypt❩ Warning: Failed renewal attempt detected. Old certificate files found. Attempting to recover…') // Edge case: check if the process succeeded (perhaps the power went out right after the certificate was // written but before we had a chance to clean up the old files.) if (fs.existsSync(certificateIdentityPath) && fs.existsSync(certificatePath)) { log(' 🚑 ❨auto-encrypt❩ A new certificate was also found. Going to delete the old one and use that.') fs.rmSync(oldCertificateIdentityPath) fs.rmSync(oldCertificatePath) } else { // The renewal process must have failed. Delete any previous state and restore the old certificate. log(' 🚑 ❨auto-encrypt❩ Cleaning up previous state and restoring old certificate…') fs.renameSync(oldCertificateIdentityPath, certificateIdentityPath) fs.renameSync(oldCertificatePath, certificatePath) } log(' 🚑 ❨auto-encrypt❩ Recovery attempt complete.') } } /** * Creates and caches a secure context, provisioning a TLS certificate in the process, if necessary. * * @category async * @access private * @param {Boolean} renewCertificate If true, will start the process of renewing the certificate * (but will continue to return the existing certificate until it is ready). * @returns {Promise} Fulfils immediately if certificate exists and does not need to be * renewed. Otherwise, fulfils when certificate has been provisioned. */ async createSecureContext (renewCertificate = false) { // If we’re provisioning a certificate for the first time, // block all other calls. If we’re renewing, we don’t // want to do that as we already have a valid certificate // to serve. if (!renewCertificate) { this.#busyCreatingSecureContextForTheFirstTime = true } // If the certificate does not already exist, provision one. if (!this.pem || renewCertificate) { // Initialise all necessary state. this.#directory = await Directory.getInstanceAsync(this.#configuration) this.#accountIdentity = new AccountIdentity(this.#configuration) AcmeRequest.initialise(this.#directory, this.#accountIdentity) this.#account = await Account.getInstanceAsync(this.#configuration) AcmeRequest.account = this.#account await this.provisionCertificate() } // Create and cache the secure context. this.#secureContext = tls.createSecureContext({ key: this.key, cert: this.pem }) // No need to do an additional check for renewal here // as setting this to false when it is already false // will not have an undesirable effect. this.#busyCreatingSecureContextForTheFirstTime = false } /** * Provisions a new Let’s Encrypt TLS certificate, persists it, and starts checking for * renewals on it every day, starting with the next day. * * @access private * @category async * @returns {Promise} Fulfils once a certificate has been provisioned. */ async provisionCertificate () { log(` 🤖 ❨auto-encrypt❩ Provisioning Let’s Encrypt certificates for ${this.#domains}.`) // Create a new order. const order = await Order.getInstanceAsync(this.#configuration, this.#accountIdentity) // Get the certificate details from the order. this.pem = order.certificate this.identity = order.certificateIdentity // Start checking for renewal updates, every day, starting tomorrow. this.startCheckingForRenewal(/* alsoCheckNow = */ false) log(` 🎉 ❨auto-encrypt❩ Successfully provisioned Let’s Encrypt certificate for ${this.#domains}.`) } /** * Starts the certificate renewal process by requesting the creation of a fresh secure context. * * @access private * @returns {Promise} Resolves once certificate is renewed and new secure context is * created and cached. * @category async */ async renewCertificate () { // // Backup the existing certificate and certificate identity (*.pem → *.pem.old). Then create a new // Order and, if it’s successful, update the certificate and certificate identity and recreate and // cache the secureContext so that the server will start using the new certificate right away. // If it’s not successful, restore the old files. // log(` 🤖 ❨auto-encrypt❩ Renewing Let’s Encrypt certificate for ${this.#domains}.`) this.stopCheckingForRenewal() // // In case old files were left behind, remove them first and then rename the current files. // (If the directory doesn’t exist, will silently do nothing.) // const certificateIdentityPath = this.#configuration.certificateIdentityPath const oldCertificateIdentityPath = `${certificateIdentityPath}.old` const certificatePath = this.#configuration.certificatePath const oldCertificatePath = `${certificatePath}.old` fs.renameSync(certificateIdentityPath, oldCertificateIdentityPath) fs.renameSync(certificatePath, oldCertificatePath) // Create a fresh secure context, renewing the certificate in the process. // Once the secure context has been created, it will automatically be used // for any new connection attempts in the future. await this.createSecureContext(/* renewCertificate = */ true) // Delete the backup of the old certificate. fs.rmSync(oldCertificateIdentityPath) fs.rmSync(oldCertificatePath) } /** * Checks if the certificate needs to be renewed (if it is within 30 days of its expiry date) and, if so, * renews it. While the method is async, the result is not awaited on usage. Instead, it is a fire-and-forget * method that’s called via a daily interval. * * @access private * @category async * @returns {Promise} Fulfils immediately if certificate doesn’t need renewal. Otherwise, fulfils once certificate * has been renewed. */ async checkForRenewal () { log( ' 🧐 ❨auto-encrypt❩ Checking if we need to renew the certificate… ') const currentDate = moment() if (currentDate.isSameOrAfter(this.#renewalDate)) { // // Certificate needs renewal. // log(` 🌱 ❨auto-encrypt❩ Certificate expires in 30 days or less. Renewing certificate…`) // Note: this is not a blocking process. We transparently start using the new certificate // when it is ready. await this.renewCertificate() log(` 🌱 ❨auto-encrypt❩ Successfully renewed Let’s Encrypt certificate.`) } else { log(' 👍 ❨auto-encrypt❩ Certificate has more than 30 days before it expires. Will check again tomorrow.') } } /** * Starts checking for certificate renewals every 24 hours. * * @param {boolean} [alsoCheckNow=false] If true, will also immediately check for renewal when the function is * called (use this when loading a previously-provisioned and persisted * certificate from disk). * @category sync * @access private */ startCheckingForRenewal (alsoCheckNow = false) { // // Check for certificate renewal now and then once every day from there on. // this.#renewalDate = this.expiryDate.clone().subtract(30, 'days') // Also check for renewal immediately if asked to. if (alsoCheckNow) { this.checkForRenewal() } // And also once a day from thereon for as long as the server is running. const onceADay = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */ * 1000 /* ms */ this.#checkForRenewalIntervalId = setInterval(this.checkForRenewal.bind(this), onceADay) log(' ⏰ ❨auto-encrypt❩ Set up timer to check for certificate renewal once a day.') } /** * Stops the timer that checks for renewal daily. Use this during housekeeping before destroying this object. * * @category sync * @access private */ stopCheckingForRenewal () { clearInterval(this.#checkForRenewalIntervalId) } parseDetails (certificatePem) { const certificate = (X509Certificate.decode(certificatePem, 'pem', {label: 'CERTIFICATE'})).tbsCertificate const serialNumber = certificate.serialNumber const issuer = certificate.issuer.value[0][0].value.toString('utf-8').slice(2).trim() const issuedAt = new Date(certificate.validity.notBefore.value) const expiresAt = new Date(certificate.validity.notAfter.value) const subject = certificate.subject.value.length > 0 ? certificate.subject.value[0][0].value.toString('utf-8').slice(2).trim() : '(No subject)' const alternativeNames = ((certificate.extensions.filter(extension => { return extension.extnID === 'subjectAlternativeName' }))[0].extnValue).map(name => name.value) return { serialNumber, issuer, subject, alternativeNames, issuedAt, expiresAt } } __changeRenewalDate (momentDate) { log(' ⚠ ❨auto-encrypt❩ Warning: changing renewal date on the certificate instance. I hope you know what you’re doing.') this.#renewalDate = momentDate } get __checkForRenewalIntervalId () { return this.#checkForRenewalIntervalId } /** * Custom inspection string. */ [util.inspect.custom] () { return `# Certificate ${!this.isProvisioned ? 'Certificate not provisioned.' : ` Key Value ──────────────── ───────────────────────── Serial number .serialNumber ${this.serialNumber} Issuer .issuer ${this.issuer} Subject .subject ${this.subject} Alternative names .alterNativeNames ${this.alternativeNames} Issue date .issueDate ${this.issueDate} Expiry date .expiryDate ${this.expiryDate} Renewal date .renewalDate ${this.renewalDate} `} ` } }