@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.)
250 lines (216 loc) • 9.95 kB
JavaScript
/**
* Abstract base request class for carrying out signed ACME requests over HTTPS.
*
* @module
* @copyright Copyright © 2020 Aral Balkan, Small Technology Foundation.
* @license AGPLv3 or later.
*/
import packageJson from '../package.json' with { type: 'json' }
import jose from 'jose'
import prepareRequest from 'bent'
import types from '../typedefs/lib/AcmeRequest.js'
import Nonce from './Nonce.js'
import Throws from './util/Throws.js'
import log from './util/log.js'
const throws = new Throws({
[Symbol.for('AcmeRequest.classNotInitialisedError')]:
() => 'You cannot create instances of the AcmeRequest class before initialising it via AcmeRequest.initialise()',
[Symbol.for('AcmeRequest.accountNotSetError')]:
() => 'You cannot issue calls that require an account KeyId without first injecting a reference to the account',
[Symbol.for('AcmeRequest.requestError')]: error => `(${error.status} ${error.type} ${error.detail})`
})
/**
* Abstract base request class for carrying out signed ACME requests over HTTPS.
*
* @alias module:lib/AcmeRequest
*/
export default class AcmeRequest {
static initialised = false
static directory = null
static accountIdentity = null
static nonce = null
static __account = null
/** @type {string} */
static autoEncryptVersion = packageJson.version
static initialise (directory = throws.ifMissing(), accountIdentity = throws.ifMissing()) {
this.directory = directory
this.accountIdentity = accountIdentity
this.nonce = new Nonce(directory)
this.initialised = true
}
static uninitialise () {
this.directory = null
this.accountIdentity = null
this.nonce = null
this.__account = null
this.initialised = false
}
static set account (_account = throws.ifMissing()) { this.__account = _account }
static get account () { return this.__account }
constructor () {
if (!AcmeRequest.initialised) {
throws.error(Symbol.for('AcmeRequest.classNotInitialisedError'))
}
}
/**
* Executes a remote Let’s Encrypt command and either returns the result or throws.
*
* @param {String} command Name of {@link Directory} command to invoke e.g. 'newAccount'
* @param {Object|String} payload Object to use as payload. For no payload, pass empty string.
* @param {Boolean} useKid Use Key ID (true) or public JWK (false) (see RFC 8555 § 6.2).
* @param {Number[]} [successCodes=[200]] Return codes accepted as success. Any other code throws.
* @param {String} [url=null] If specified, use this URL, ignoring the command parameter.
* @param {Boolean} [parseResponseBodyAsJSON=true] Parse response body as JSON (true) or as string (false).
* @returns {types.ResponseObject}
*/
async execute (
command = throws.ifMissing(),
payload = throws.ifMissing(),
useKid = true,
successCodes = [200],
url = null,
parseResponseBodyAsJSON = true
) {
if (useKid && AcmeRequest.account === null) { throws.error(Symbol.for('AcmeRequest.accountNotSetError')) }
const preparedRequest = await this.prepare(command, payload, useKid, successCodes, url)
const responseObject = await this._execute(preparedRequest, parseResponseBodyAsJSON)
return responseObject
}
/**
* Executes a prepared request.
*
* @param {types.PreparedRequest} preparedRequest The prepared request, ready to be executed.
* @param {Boolean} parseResponseBodyAsJSON Should the request body be parsed as JSON (true) or should
* the native response object be returned (false).
* @returns {types.ResponseObject}
*/
async _execute (preparedRequest = throws.ifMissing(), parseResponseBodyAsJSON = throws.ifMissing()) {
const { signedRequest, httpsRequest, httpsHeaders, originalRequestDetails } = preparedRequest
let response, errorHeaders, errorBody
try {
response = await httpsRequest('', signedRequest, httpsHeaders)
} catch (error) {
errorBody = error.responseBody
errorHeaders = error.responseHeaders
}
// The error body is a promise. Wait for it to resolve.
if (errorBody) {
const errorBodyBuffer = await errorBody
// If the error body is JSON (i.e., as expected to be returned from Let’s Encrypt),
// handle it. If not (for whatever reason), still handle the error gracefully.
let error = null
const errorBodyString = errorBodyBuffer.toString('utf-8')
try {
error = JSON.parse(errorBodyString)
} catch (_) {
error = {
status: -1,
type: 'Unexpected error',
detail: errorBodyString
}
}
// According to RFC 8555 § 6.5, a bad nonce error should result in retry attempt.
if (error.status === 400 && error.type === 'urn:ietf:params:acme:error:badNonce') {
log(' 🔄 ❨auto-encrypt❩ Server returned a bad nonce error. Retrying with provided nonce. (RFC 8555 § 6.5)')
const serverProvidedNonce = errorHeaders['replay-nonce']
// Take the original request details (arguments array passed to the prepare() method) and
// re-prepare and retry the request, replacing the nonce (last argument), with the one provided
// by the ACME server.
const originalRequestWithServerProvidedNonce = originalRequestDetails
originalRequestWithServerProvidedNonce[originalRequestWithServerProvidedNonce.length-1] = serverProvidedNonce
return await this._execute(
await this.prepare(...originalRequestWithServerProvidedNonce),
parseResponseBodyAsJSON
)
}
throws.error(Symbol.for('AcmeRequest.requestError'), error)
}
// Always save the fresh nonce returned from API calls.
const freshNonce = response.headers['replay-nonce']
AcmeRequest.nonce.set(freshNonce)
// The response returned is the raw response object. Let’s consume
// it and return a more relevant response.
const headers = response.headers
const responseBodyBuffer = await this.getBuffer(response)
let body = responseBodyBuffer.toString('utf-8')
if (parseResponseBodyAsJSON) {
body = JSON.parse(body)
}
return {
headers,
body
}
}
/**
* Concatenates the output of a stream and returns a buffer. Taken from the bent module.
*
* @param {stream} stream A Node stream.
* @returns {Promise<Buffer>} The concatenated output of the Node stream.
*/
getBuffer (stream) {
return new Promise((resolve, reject) => {
const parts = []
stream.on('error', reject)
stream.on('end', () => resolve(Buffer.concat(parts)))
stream.on('data', d => parts.push(d))
})
}
/**
* Separate the preparation of a request from the execution of it so we can easily test
* that different request configurations conform to our expectations.
*
* @param {String} command (Required) Name of Let’s Encrypt command to invoke (see Directory).
* (sans 'Url' suffix). e.g. 'newAccount', 'newOrder', etc.
* @param {Object|String} payload (Required) Either an object to use as the payload or, if there is no
* payload, an empty string.
* @param {Boolean} useKid (Required) Should request use a Key ID (true) or, public JWK (false).
* (See RFC 8555 § 6.2 Request Authentication)
* @param {Number[]} [successCodes=[200]] Optional array of codes that signals success. Any other code throws.
* @param {String} [url=null] If specified, will use this URL directly, ignoring the value in
* the command parameter.
*
* @returns {types.PreparedRequest}
*/
async prepare (
command = throws.ifMissing(),
payload = throws.ifMissing(),
useKid = throws.ifMissing(),
successCodes = [200],
url = null,
nonce = null
) {
if (useKid && AcmeRequest.account === null) { throws.error(Symbol.for('AcmeRequest.accountNotSetError')) }
// We will also return the original request details in case the call needs to be retried later.
// Note: we have to create our own object using the actual individual argument values instead of
// ===== the arguments array as the latter does not reflect default parameters.
const originalRequestDetails = [command, payload, useKid, successCodes, url, nonce]
url = url || AcmeRequest.directory[`${command}Url`]
const protectedHeader = {
alg: 'RS256',
nonce: nonce || await AcmeRequest.nonce.get(),
url
}
if (useKid) {
// The kid is the account location URL as previously returned by the ACME server.
protectedHeader.kid = AcmeRequest.account.kid
} else {
// If we’re not using the kid, we must use the public JWK (see RFC 8555 § 6.2 Request Authentication)
protectedHeader.jwk = AcmeRequest.accountIdentity.publicJWK
}
const signedRequest = jose.JWS.sign.flattened(payload, AcmeRequest.accountIdentity.key, protectedHeader)
const httpsHeaders = {
'Content-Type': 'application/jose+json',
'User-Agent': `small-tech.org-auto-encrypt/${AcmeRequest.autoEncryptVersion}`,
'Accept-Language': 'en-US'
}
// Prepare a new account request (RFC 8555 § 7.3 Account Management)
const httpsRequest = prepareRequest('POST', url, /* acceptable responses are */ ...successCodes)
return {
protectedHeader,
signedRequest,
httpsRequest,
httpsHeaders,
originalRequestDetails
}
}
}