diff --git a/src/cert/acme2.js b/src/cert/acme2.js index 9b2cf689c..ae599e128 100644 --- a/src/cert/acme2.js +++ b/src/cert/acme2.js @@ -2,14 +2,12 @@ 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'), @@ -272,36 +270,17 @@ Acme2.prototype.signCertificate = async function (domain, finalizationUrl, csrDe 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) { +Acme2.prototype.createKeyAndCsr = async function (hostname, keyFilePath, csrFilePath) { 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); + if (safe.fs.existsSync(keyFilePath)) { + debug('createKeyAndCsr: reuse the key for renewal at %s', keyFilePath); } 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); + if (!safe.fs.writeFileSync(keyFilePath, key)) throw new BoxError(BoxError.FS_ERROR, safe.error); - debug('createKeyAndCsr: key file saved at %s', privateKeyFile); + debug('createKeyAndCsr: key file saved at %s', keyFilePath); } // 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/) @@ -310,22 +289,19 @@ Acme2.prototype.createKeyAndCsr = async function (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}`); + const csrDer = safe.child_process.execSync(`openssl req -new -key ${keyFilePath} -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 + if (!safe.fs.writeFileSync(csrFilePath, 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); + debug('createKeyAndCsr: csr file (DER) saved at %s', csrFilePath); return csrDer; }; -Acme2.prototype.downloadCertificate = async function (hostname, certUrl) { +Acme2.prototype.downloadCertificate = async function (hostname, certUrl, certFilePath) { 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'); @@ -335,19 +311,17 @@ Acme2.prototype.downloadCertificate = async function (hostname, certUrl) { 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); + if (!safe.fs.writeFileSync(certFilePath, fullChainPem)) throw new BoxError(BoxError.FS_ERROR, safe.error); - debug(`downloadCertificate: cert file for ${hostname} saved at ${certificateFile}`); + debug(`downloadCertificate: cert file for ${hostname} saved at ${certFilePath}`); }); }; -Acme2.prototype.prepareHttpChallenge = async function (hostname, domain, authorization) { +Acme2.prototype.prepareHttpChallenge = async function (hostname, domain, authorization, acmeChallengesDir) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof authorization, 'object'); + assert.strictEqual(typeof acmeChallengesDir, 'string'); debug('prepareHttpChallenge: challenges: %j', authorization); let httpChallenges = authorization.challenges.filter(function(x) { return x.type === 'http-01'; }); @@ -358,21 +332,22 @@ Acme2.prototype.prepareHttpChallenge = async function (hostname, domain, authori let keyAuthorization = this.getKeyAuthorization(challenge.token); - debug('prepareHttpChallenge: writing %s to %s', keyAuthorization, path.join(paths.ACME_CHALLENGES_DIR, challenge.token)); + debug('prepareHttpChallenge: writing %s to %s', keyAuthorization, path.join(acmeChallengesDir, challenge.token)); - if (!fs.writeFileSync(path.join(paths.ACME_CHALLENGES_DIR, challenge.token), keyAuthorization)) throw new BoxError(BoxError.FS_ERROR, safe.error); + if (!fs.writeFileSync(path.join(acmeChallengesDir, challenge.token), keyAuthorization)) throw new BoxError(BoxError.FS_ERROR, safe.error); return challenge; }; -Acme2.prototype.cleanupHttpChallenge = async function (hostname, domain, challenge) { +Acme2.prototype.cleanupHttpChallenge = async function (hostname, domain, challenge, acmeChallengesDir) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof challenge, 'object'); + assert.strictEqual(typeof acmeChallengesDir, 'string'); - debug('cleanupHttpChallenge: unlinking %s', path.join(paths.ACME_CHALLENGES_DIR, challenge.token)); + debug('cleanupHttpChallenge: unlinking %s', path.join(acmeChallengesDir, challenge.token)); - await fs.promises.unlink(path.join(paths.ACME_CHALLENGES_DIR, challenge.token)); + await fs.promises.unlink(path.join(acmeChallengesDir, challenge.token)); }; function getChallengeSubdomain(hostname, domain) { @@ -447,10 +422,11 @@ Acme2.prototype.cleanupDnsChallenge = async function (hostname, domain, challeng }); }; -Acme2.prototype.prepareChallenge = async function (hostname, domain, authorizationUrl) { +Acme2.prototype.prepareChallenge = async function (hostname, domain, authorizationUrl, acmeChallengesDir) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof authorizationUrl, 'string'); + assert.strictEqual(typeof acmeChallengesDir, 'string'); debug(`prepareChallenge: http: ${this.performHttpAuthorization}`); @@ -460,29 +436,33 @@ Acme2.prototype.prepareChallenge = async function (hostname, domain, authorizati const authorization = response.body; if (this.performHttpAuthorization) { - return await this.prepareHttpChallenge(hostname, domain, authorization); + return await this.prepareHttpChallenge(hostname, domain, authorization, acmeChallengesDir); } else { return await this.prepareDnsChallenge(hostname, domain, authorization); } }; -Acme2.prototype.cleanupChallenge = async function (hostname, domain, challenge) { +Acme2.prototype.cleanupChallenge = async function (hostname, domain, challenge, acmeChallengesDir) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof challenge, 'object'); + assert.strictEqual(typeof acmeChallengesDir, 'string'); debug(`cleanupChallenge: http: ${this.performHttpAuthorization}`); if (this.performHttpAuthorization) { - await this.cleanupHttpChallenge(hostname, domain, challenge); + await this.cleanupHttpChallenge(hostname, domain, challenge, acmeChallengesDir); } else { await this.cleanupDnsChallenge(hostname, domain, challenge); } }; -Acme2.prototype.acmeFlow = async function (hostname, domain) { +Acme2.prototype.acmeFlow = async function (hostname, domain, paths) { assert.strictEqual(typeof hostname, 'string'); assert.strictEqual(typeof domain, 'string'); + assert.strictEqual(typeof paths, 'object'); + + const { certFilePath, keyFilePath, csrFilePath, acmeChallengesDir } = paths; await this.registerUser(); const { order, orderUrl } = await this.newOrder(hostname); @@ -491,16 +471,16 @@ Acme2.prototype.acmeFlow = async function (hostname, domain) { const authorizationUrl = order.authorizations[i]; debug(`acmeFlow: authorizing ${authorizationUrl}`); - const challenge = await this.prepareChallenge(hostname, domain, authorizationUrl); + const challenge = await this.prepareChallenge(hostname, domain, authorizationUrl, acmeChallengesDir); await this.notifyChallengeReady(challenge); await this.waitForChallenge(challenge); - const csrDer = await this.createKeyAndCsr(hostname); + const csrDer = await this.createKeyAndCsr(hostname, keyFilePath, csrFilePath); await this.signCertificate(hostname, order.finalize, csrDer); const certUrl = await this.waitForOrder(orderUrl); - await this.downloadCertificate(hostname, certUrl); + await this.downloadCertificate(hostname, certUrl, certFilePath); try { - await this.cleanupChallenge(hostname, domain, challenge); + await this.cleanupChallenge(hostname, domain, challenge, acmeChallengesDir); } catch (cleanupError) { debug('acmeFlow: ignoring error when cleaning up challenge:', cleanupError); } @@ -521,9 +501,10 @@ Acme2.prototype.loadDirectory = async function () { }); }; -Acme2.prototype.getCertificate = async function (vhost, domain) { +Acme2.prototype.getCertificate = async function (vhost, domain, paths) { assert.strictEqual(typeof vhost, 'string'); assert.strictEqual(typeof domain, 'string'); + assert.strictEqual(typeof paths, 'object'); debug(`getCertificate: start acme flow for ${vhost} from ${this.caDirectory}`); @@ -533,17 +514,13 @@ Acme2.prototype.getCertificate = async function (vhost, domain) { } 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`) }; + await this.acmeFlow(vhost, domain, paths); }; -function getCertificate(vhost, domain, options, callback) { +function getCertificate(vhost, domain, paths, 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 paths, 'object'); assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof callback, 'function'); @@ -552,8 +529,6 @@ function getCertificate(vhost, domain, options, callback) { debug(`getCertificate: attempt ${attempt++}`); let acme = new Acme2(options || { }); - acme.getCertificate(vhost, domain).then((result) => { - callback(null, result.certFilePath, result.keyFilePath); - }).catch(retryCallback); + acme.getCertificate(vhost, domain, paths).then(callback).catch(retryCallback); }, callback); } diff --git a/src/reverseproxy.js b/src/reverseproxy.js index d53e47243..48722ce75 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -278,7 +278,7 @@ function getAcmeCertificatePathSync(vhost, domainObject) { assert.strictEqual(typeof vhost, 'string'); // this can contain wildcard domain (for alias domains) assert.strictEqual(typeof domainObject, 'object'); - let certName, certFilePath, keyFilePath, csrFilePath; + let certName, certFilePath, keyFilePath, csrFilePath, acmeChallengesDir = paths.ACME_CHALLENGES_DIR; if (vhost !== domainObject.domain && domainObject.tlsConfig.wildcard) { // bare domain is not part of wildcard SAN certName = domains.makeWildcard(vhost).replace('*.', '_.'); @@ -292,7 +292,7 @@ function getAcmeCertificatePathSync(vhost, domainObject) { csrFilePath = path.join(paths.NGINX_CERT_DIR, `${vhost}.csr`); } - return { certName, certFilePath, keyFilePath, csrFilePath }; + return { certName, certFilePath, keyFilePath, csrFilePath, acmeChallengesDir }; } function setAppCertificate(location, domainObject, certificate, callback) { @@ -344,7 +344,7 @@ async function checkAppCertificate(vhost, domainObject) { assert.strictEqual(typeof domainObject, 'object'); const subdomain = vhost.substr(0, vhost.length - domainObject.domain.length - 1); - const certificate = await apps.getCertificate(subdomain, domainObject); + const certificate = await apps.getCertificate(subdomain, domainObject.domain); if (!certificate) return null; const { certFilePath, keyFilePath } = getAppCertificatePathSync(vhost); @@ -375,6 +375,26 @@ async function checkAcmeCertificate(vhost, domainObject) { return { certFilePath, keyFilePath }; } +async function updateCertBlobs(vhost, domainObject) { + assert.strictEqual(typeof vhost, 'string'); // this can contain wildcard domain (for alias domains) + assert.strictEqual(typeof domainObject, 'object'); + + const { certName, certFilePath, keyFilePath, csrFilePath } = getAcmeCertificatePathSync(vhost, domainObject); + + const privateKey = safe.fs.readFileSync(keyFilePath); + if (!privateKey) throw new BoxError(BoxError.FS_ERROR, `Failed to read private key: ${safe.error.message}`); + + const cert = safe.fs.readFileSync(certFilePath); + if (!cert) throw new BoxError(BoxError.FS_ERROR, `Failed to read cert: ${safe.error.message}`); + + const csr = safe.fs.readFileSync(csrFilePath); + if (!csr) throw new BoxError(BoxError.FS_ERROR, `Failed to read csr: ${safe.error.message}`); + + await blobs.set(`${blobs.CERT_PREFIX}-${certName}.key`, privateKey); + await blobs.set(`${blobs.CERT_PREFIX}-${certName}.cert`, cert); + await blobs.set(`${blobs.CERT_PREFIX}-${certName}.csr`, csr); +} + function ensureCertificate(vhost, domain, auditSource, callback) { assert.strictEqual(typeof vhost, 'string'); assert.strictEqual(typeof domain, 'string'); @@ -410,8 +430,9 @@ function ensureCertificate(vhost, domain, auditSource, callback) { debug('ensureCertificate: getting certificate for %s with options %j', vhost, _.omit(apiOptions, 'accountKeyPem')); - acmeApi.getCertificate(vhost, domain, apiOptions, function (error, certFilePath, keyFilePath) { - debug(`ensureCertificate: error: ${error ? error.message : 'null'} cert: ${certFilePath || 'null'}`); + const acmePaths = getAcmeCertificatePathSync(vhost, domainObject); + acmeApi.getCertificate(vhost, domain, acmePaths, apiOptions, async function (error) { + debug(`ensureCertificate: error: ${error ? error.message : 'null'} cert: ${acmePaths.certFilePath || 'null'}`); eventlog.add(currentBundle ? eventlog.ACTION_CERTIFICATE_RENEWAL : eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: vhost, errorMessage: error ? error.message : '' }); @@ -420,7 +441,10 @@ function ensureCertificate(vhost, domain, auditSource, callback) { return callback(null, currentBundle, { renewed: false }); } - if (certFilePath && keyFilePath) return callback(null, { certFilePath, keyFilePath }, { renewed: true }); + if (!error) { + [error] = await safe(updateCertBlobs(vhost, domainObject)); + if (!error) return callback(null, { certFilePath: acmePaths.certFilePath, keyFilePath: acmePaths.keyFilePath }, { renewed: true }); + } debug(`ensureCertificate: renewal of ${vhost} failed. using fallback certificates for ${domain}`);