'use strict'; const assert = require('assert'), async = require('async'), blobs = require('../blobs.js'), BoxError = require('../boxerror.js'), crypto = require('crypto'), debug = require('debug')('box:cert/acme2'), domains = require('../domains.js'), fs = require('fs'), path = require('path'), paths = require('../paths.js'), promiseRetry = require('../promise-retry.js'), superagent = require('superagent'), safe = require('safetydance'), _ = require('underscore'); const CA_PROD_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory', CA_STAGING_DIRECTORY_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory'; exports = module.exports = { getCertificate, // testing _name: 'acme', _getChallengeSubdomain: getChallengeSubdomain }; // http://jose.readthedocs.org/en/latest/ // https://www.ietf.org/proceedings/92/slides/slides-92-acme-1.pdf // https://community.letsencrypt.org/t/list-of-client-implementations/2103 function Acme2(options) { assert.strictEqual(typeof options, 'object'); this.accountKeyPem = options.accountKeyPem; // Buffer this.email = options.email; this.keyId = null; this.caDirectory = options.prod ? CA_PROD_DIRECTORY_URL : CA_STAGING_DIRECTORY_URL; this.directory = {}; this.performHttpAuthorization = !!options.performHttpAuthorization; this.wildcard = !!options.wildcard; } // urlsafe base64 encoding (jose) function urlBase64Encode(string) { return string.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } function b64(str) { var buf = Buffer.isBuffer(str) ? str : Buffer.from(str); return urlBase64Encode(buf.toString('base64')); } function getModulus(pem) { assert(Buffer.isBuffer(pem)); var stdout = safe.child_process.execSync('openssl rsa -modulus -noout', { input: pem, encoding: 'utf8' }); if (!stdout) return null; var match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m); if (!match) return null; return Buffer.from(match[1], 'hex'); } Acme2.prototype.sendSignedRequest = async function (url, payload) { assert.strictEqual(typeof url, 'string'); assert.strictEqual(typeof payload, 'string'); assert(Buffer.isBuffer(this.accountKeyPem)); const that = this; let header = { url: url, alg: 'RS256' }; // keyId is null when registering account if (this.keyId) { header.kid = this.keyId; } else { header.jwk = { e: b64(Buffer.from([0x01, 0x00, 0x01])), // exponent - 65537 kty: 'RSA', n: b64(getModulus(this.accountKeyPem)) }; } const payload64 = b64(payload); let [error, response] = await safe(superagent.get(this.directory.newNonce).timeout(30000).ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${error.message}`); if (response.status !== 204) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid response code when fetching nonce : ${response.status}`); const nonce = response.headers['Replay-Nonce'.toLowerCase()]; if (!nonce) throw new BoxError(BoxError.EXTERNAL_ERROR, 'No nonce in response'); debug('sendSignedRequest: using nonce %s for url %s', nonce, url); const protected64 = b64(JSON.stringify(_.extend({ }, header, { nonce: nonce }))); const signer = crypto.createSign('RSA-SHA256'); signer.update(protected64 + '.' + payload64, 'utf8'); const signature64 = urlBase64Encode(signer.sign(that.accountKeyPem, 'base64')); const data = { protected: protected64, payload: payload64, signature: signature64 }; [error, response] = await safe(superagent.post(url).send(data).set('Content-Type', 'application/jose+json').set('User-Agent', 'acme-cloudron').timeout(30000).ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Network error sending signed request: ${error.message}`); return response; }; // https://tools.ietf.org/html/rfc8555#section-6.3 Acme2.prototype.postAsGet = async function (url) { return await this.sendSignedRequest(url, ''); }; Acme2.prototype.updateContact = async function (registrationUri) { assert.strictEqual(typeof registrationUri, 'string'); debug(`updateContact: registrationUri: ${registrationUri} email: ${this.email}`); // https://github.com/ietf-wg-acme/acme/issues/30 const payload = { contact: [ 'mailto:' + this.email ] }; const result = await this.sendSignedRequest(registrationUri, JSON.stringify(payload)); if (result.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to update contact. Expecting 200, got ${result.status} ${JSON.stringify(result.body)}`); debug(`updateContact: contact of user updated to ${this.email}`); }; Acme2.prototype.registerUser = async function () { const payload = { termsOfServiceAgreed: true }; debug('registerUser: registering user'); const result = await this.sendSignedRequest(this.directory.newAccount, JSON.stringify(payload)); // 200 if already exists. 201 for new accounts if (result.status !== 200 && result.status !== 201) return new BoxError(BoxError.EXTERNAL_ERROR, `Failed to register new account. Expecting 200 or 201, got ${result.status} ${JSON.stringify(result.body)}`); debug(`registerUser: user registered keyid: ${result.headers.location}`); this.keyId = result.headers.location; await this.updateContact(result.headers.location); }; Acme2.prototype.newOrder = async function (domain) { assert.strictEqual(typeof domain, 'string'); const payload = { identifiers: [{ type: 'dns', value: domain }] }; debug(`newOrder: ${domain}`); const result = await this.sendSignedRequest(this.directory.newOrder, JSON.stringify(payload)); if (result.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, `Forbidden sending new order: ${result.body.detail}`); if (result.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to send new order. Expecting 201, got ${result.statusCode} ${JSON.stringify(result.body)}`); debug('newOrder: created order %s %j', domain, result.body); const order = result.body, orderUrl = result.headers.location; if (!Array.isArray(order.authorizations)) throw new BoxError(BoxError.EXTERNAL_ERROR, 'invalid authorizations in order'); if (typeof order.finalize !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, 'invalid finalize in order'); if (typeof orderUrl !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, 'invalid order location in order header'); return { order, orderUrl }; }; Acme2.prototype.waitForOrder = async function (orderUrl) { assert.strictEqual(typeof orderUrl, 'string'); debug(`waitForOrder: ${orderUrl}`); return await promiseRetry({ times: 15, interval: 20000 }, async () => { debug('waitForOrder: getting status'); const result = await this.postAsGet(orderUrl); if (result.status !== 200) { debug(`waitForOrder: invalid response code getting uri ${result.status}`); throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response code: ${result.status}`); } debug('waitForOrder: status is "%s %j', result.body.status, result.body); if (result.body.status === 'pending' || result.body.status === 'processing') throw new BoxError(BoxError.TRY_AGAIN, `Request is in ${result.body.status} state`); else if (result.body.status === 'valid' && result.body.certificate) return result.body.certificate; else throw new BoxError(BoxError.EXTERNAL_ERROR, `Unexpected status or invalid response: ${JSON.stringify(result.body)}`); }); }; Acme2.prototype.getKeyAuthorization = function (token) { assert(Buffer.isBuffer(this.accountKeyPem)); let jwk = { e: b64(Buffer.from([0x01, 0x00, 0x01])), // Exponent - 65537 kty: 'RSA', n: b64(getModulus(this.accountKeyPem)) }; let shasum = crypto.createHash('sha256'); shasum.update(JSON.stringify(jwk)); let thumbprint = urlBase64Encode(shasum.digest('base64')); return token + '.' + thumbprint; }; Acme2.prototype.notifyChallengeReady = async function (challenge) { assert.strictEqual(typeof challenge, 'object'); // { type, status, url, token } debug('notifyChallengeReady: %s was met', challenge.url); const keyAuthorization = this.getKeyAuthorization(challenge.token); const payload = { resource: 'challenge', keyAuthorization: keyAuthorization }; const result = await this.sendSignedRequest(challenge.url, JSON.stringify(payload)); if (result.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to notify challenge. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`); }; Acme2.prototype.waitForChallenge = async function (challenge) { assert.strictEqual(typeof challenge, 'object'); debug('waitingForChallenge: %j', challenge); await promiseRetry({ times: 15, interval: 20000 }, async () => { debug('waitingForChallenge: getting status'); const result = await this.postAsGet(challenge.url); if (result.status !== 200) { debug(`waitForChallenge: invalid response code getting uri ${result.status}`); throw new BoxError(BoxError.EXTERNAL_ERROR, 'Bad response code:' + result.statusCode); } debug(`waitForChallenge: status is "${result.body.status}" "${JSON.stringify(result.body)}"`); if (result.body.status === 'pending') throw new BoxError(BoxError.TRY_AGAIN); else if (result.body.status === 'valid') return; else throw new BoxError(BoxError.EXTERNAL_ERROR, `Unexpected status: ${result.body.status}`); }); }; // https://community.letsencrypt.org/t/public-beta-rate-limits/4772 for rate limits Acme2.prototype.signCertificate = async function (domain, finalizationUrl, csrDer) { assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof finalizationUrl, 'string'); assert(Buffer.isBuffer(csrDer)); const payload = { csr: b64(csrDer) }; debug('signCertificate: sending sign request'); const result = await this.sendSignedRequest(finalizationUrl, JSON.stringify(payload)); // 429 means we reached the cert limit for this domain if (result.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to sign certificate. Expecting 200, got ${result.status} ${JSON.stringify(result.body)}`); }; Acme2.prototype.createKeyAndCsr = async function (hostname) { assert.strictEqual(typeof hostname, 'string'); const outdir = paths.NGINX_CERT_DIR; const certName = hostname.replace('*.', '_.'); const csrFile = path.join(outdir, `${certName}.csr`); const privateKeyFile = path.join(outdir, `${certName}.key`); let privateKey = await blobs.get(`${blobs.CERT_PREFIX}-${certName}.key`); if (!privateKeyFile) { debug(`createKeyAndCsr: reuse the key for renewal at ${privateKeyFile}`); } else { debug('createKeyAndCsr: create new key'); privateKey = safe.child_process.execSync('openssl ecparam -genkey -name secp384r1'); // openssl ecparam -list_curves if (!privateKey) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error); await blobs.set(`${blobs.CERT_PREFIX}-${certName}.key`, privateKey); } if (!safe.fs.writeFileSync(privateKeyFile, privateKey)) throw new BoxError(BoxError.FS_ERROR, `Could not write private key: ${safe.error.message}`); debug(`createKeyAndCsr: key file saved at ${privateKeyFile}`); if (safe.fs.existsSync(privateKeyFile)) { // in some old releases, csr file was corrupt. so always regenerate it debug('createKeyAndCsr: reuse the key for renewal at %s', privateKeyFile); } else { let key = safe.child_process.execSync('openssl ecparam -genkey -name secp384r1'); // openssl ecparam -list_curves if (!key) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error); if (!safe.fs.writeFileSync(privateKeyFile, key)) throw new BoxError(BoxError.FS_ERROR, safe.error); debug('createKeyAndCsr: key file saved at %s', privateKeyFile); } // OCSP must-staple is currently disabled because nginx does not provide staple on the first request (https://forum.cloudron.io/topic/4917/ocsp-stapling-for-tls-ssl/) // ' -addext "tlsfeature = status_request"'; // this adds OCSP must-staple const extensionArgs = `-addext "subjectAltName = DNS:${hostname}"` + ' -addext "basicConstraints = CA:FALSE"' // this is not for a CA cert. cannot sign other certs with this + ' -addext "keyUsage = nonRepudiation, digitalSignature, keyEncipherment"'; const csrDer = safe.child_process.execSync(`openssl req -new -key ${privateKeyFile} -outform DER -subj /CN=${hostname} ${extensionArgs}`); if (!csrDer) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error); await blobs.set(`${blobs.CERT_PREFIX}-${certName}.csr`, csrDer); if (!safe.fs.writeFileSync(csrFile, csrDer)) throw new BoxError(BoxError.FS_ERROR, safe.error); // bookkeeping. inspect with openssl req -text -noout -in hostname.csr -inform der debug('createKeyAndCsr: csr file (DER) saved at %s', csrFile); return csrDer; }; Acme2.prototype.downloadCertificate = async function (hostname, certUrl) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof certUrl, 'string'); const outdir = paths.NGINX_CERT_DIR; await promiseRetry({ times: 5, interval: 20000 }, async () => { debug('downloadCertificate: downloading certificate'); const result = await this.postAsGet(certUrl); if (result.statusCode === 202) throw new BoxError(BoxError.TRY_AGAIN, 'Retry downloading certificate'); if (result.statusCode !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Failed to get cert. Expecting 200, got ${result.statusCode} ${JSON.stringify(result.body)}`); const fullChainPem = result.body; // buffer const certName = hostname.replace('*.', '_.'); const certificateFile = path.join(outdir, `${certName}.cert`); await blobs.set(`${blobs.CERT_PREFIX}-${certName}.cert`, fullChainPem); if (!safe.fs.writeFileSync(certificateFile, fullChainPem)) throw new BoxError(BoxError.FS_ERROR, safe.error); debug(`downloadCertificate: cert file for ${hostname} saved at ${certificateFile}`); }); }; Acme2.prototype.prepareHttpChallenge = async function (hostname, domain, authorization) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof authorization, 'object'); debug('prepareHttpChallenge: challenges: %j', authorization); let httpChallenges = authorization.challenges.filter(function(x) { return x.type === 'http-01'; }); if (httpChallenges.length === 0) throw new BoxError(BoxError.EXTERNAL_ERROR, 'no http challenges'); let challenge = httpChallenges[0]; debug('prepareHttpChallenge: preparing for challenge %j', challenge); let keyAuthorization = this.getKeyAuthorization(challenge.token); debug('prepareHttpChallenge: writing %s to %s', keyAuthorization, path.join(paths.ACME_CHALLENGES_DIR, challenge.token)); if (!fs.writeFileSync(path.join(paths.ACME_CHALLENGES_DIR, challenge.token), keyAuthorization)) throw new BoxError(BoxError.FS_ERROR, safe.error); return challenge; }; Acme2.prototype.cleanupHttpChallenge = async function (hostname, domain, challenge) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof challenge, 'object'); debug('cleanupHttpChallenge: unlinking %s', path.join(paths.ACME_CHALLENGES_DIR, challenge.token)); await fs.promises.unlink(path.join(paths.ACME_CHALLENGES_DIR, challenge.token)); }; function getChallengeSubdomain(hostname, domain) { let challengeSubdomain; if (hostname === domain) { challengeSubdomain = '_acme-challenge'; } else if (hostname.includes('*')) { // wildcard let subdomain = hostname.slice(0, -domain.length - 1); challengeSubdomain = subdomain ? subdomain.replace('*', '_acme-challenge') : '_acme-challenge'; } else { challengeSubdomain = '_acme-challenge.' + hostname.slice(0, -domain.length - 1); } debug(`getChallengeSubdomain: challenge subdomain for hostname ${hostname} at domain ${domain} is ${challengeSubdomain}`); return challengeSubdomain; } Acme2.prototype.prepareDnsChallenge = async function (hostname, domain, authorization) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof authorization, 'object'); debug('prepareDnsChallenge: challenges: %j', authorization); const dnsChallenges = authorization.challenges.filter(function(x) { return x.type === 'dns-01'; }); if (dnsChallenges.length === 0) throw new BoxError(BoxError.EXTERNAL_ERROR, 'no dns challenges'); const challenge = dnsChallenges[0]; const keyAuthorization = this.getKeyAuthorization(challenge.token); const shasum = crypto.createHash('sha256'); shasum.update(keyAuthorization); const txtValue = urlBase64Encode(shasum.digest('base64')); const challengeSubdomain = getChallengeSubdomain(hostname, domain); debug(`prepareDnsChallenge: update ${challengeSubdomain} with ${txtValue}`); return new Promise((resolve, reject) => { domains.upsertDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ], function (error) { if (error) return reject(error); domains.waitForDnsRecord(challengeSubdomain, domain, 'TXT', txtValue, { times: 200 }, function (error) { if (error) return reject(error); resolve(challenge); }); }); }); }; Acme2.prototype.cleanupDnsChallenge = async function (hostname, domain, challenge) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof challenge, 'object'); const keyAuthorization = this.getKeyAuthorization(challenge.token); let shasum = crypto.createHash('sha256'); shasum.update(keyAuthorization); const txtValue = urlBase64Encode(shasum.digest('base64')); let challengeSubdomain = getChallengeSubdomain(hostname, domain); debug(`cleanupDnsChallenge: remove ${challengeSubdomain} with ${txtValue}`); return new Promise((resolve, reject) => { domains.removeDnsRecords(challengeSubdomain, domain, 'TXT', [ `"${txtValue}"` ], function (error) { if (error) return reject(error); resolve(null); }); }); }; Acme2.prototype.prepareChallenge = async function (hostname, domain, authorizationUrl) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof authorizationUrl, 'string'); debug(`prepareChallenge: http: ${this.performHttpAuthorization}`); const response = await this.postAsGet(authorizationUrl); if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid response code getting authorization : ${response.status}`); const authorization = response.body; if (this.performHttpAuthorization) { return await this.prepareHttpChallenge(hostname, domain, authorization); } else { return await this.prepareDnsChallenge(hostname, domain, authorization); } }; Acme2.prototype.cleanupChallenge = async function (hostname, domain, challenge) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof challenge, 'object'); debug(`cleanupChallenge: http: ${this.performHttpAuthorization}`); if (this.performHttpAuthorization) { await this.cleanupHttpChallenge(hostname, domain, challenge); } else { await this.cleanupDnsChallenge(hostname, domain, challenge); } }; Acme2.prototype.acmeFlow = async function (hostname, domain) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); await this.registerUser(); const { order, orderUrl } = await this.newOrder(hostname); for (let i = 0; i < order.authorizations.length; i++) { const authorizationUrl = order.authorizations[i]; debug(`acmeFlow: authorizing ${authorizationUrl}`); const challenge = await this.prepareChallenge(hostname, domain, authorizationUrl); await this.notifyChallengeReady(challenge); await this.waitForChallenge(challenge); const csrDer = await this.createKeyAndCsr(hostname); await this.signCertificate(hostname, order.finalize, csrDer); const certUrl = await this.waitForOrder(orderUrl); await this.downloadCertificate(hostname, certUrl); try { await this.cleanupChallenge(hostname, domain, challenge); } catch (cleanupError) { debug('acmeFlow: ignoring error when cleaning up challenge:', cleanupError); } } }; Acme2.prototype.loadDirectory = async function () { await promiseRetry({ times: 3, interval: 20000 }, async () => { const response = await superagent.get(this.caDirectory).timeout(30000).ok(() => true); if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid response code when fetching directory : ${response.status}`); if (typeof response.body.newNonce !== 'string' || typeof response.body.newOrder !== 'string' || typeof response.body.newAccount !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid response body : ${response.body}`); this.directory = response.body; }); }; Acme2.prototype.getCertificate = async function (vhost, domain) { assert.strictEqual(typeof vhost, 'string'); assert.strictEqual(typeof domain, 'string'); debug(`getCertificate: start acme flow for ${vhost} from ${this.caDirectory}`); if (vhost !== domain && this.wildcard) { // bare domain is not part of wildcard SAN vhost = domains.makeWildcard(vhost); debug(`getCertificate: will get wildcard cert for ${vhost}`); } await this.loadDirectory(); await this.acmeFlow(vhost, domain); const outdir = paths.NGINX_CERT_DIR; const certName = vhost.replace('*.', '_.'); return { certFilePath: path.join(outdir, `${certName}.cert`), keyFilePath: path.join(outdir, `${certName}.key`) }; }; function getCertificate(vhost, domain, options, callback) { assert.strictEqual(typeof vhost, 'string'); // this can also be a wildcard domain (for alias domains) assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof callback, 'function'); let attempt = 1; async.retry({ times: 3, interval: 0 }, function (retryCallback) { debug(`getCertificate: attempt ${attempt++}`); let acme = new Acme2(options || { }); acme.getCertificate(vhost, domain).then((result) => { callback(null, result.certFilePath, result.keyFilePath); }).catch(retryCallback); }, callback); }