diff --git a/CHANGES b/CHANGES index 785d9ee45..7b841cc97 100644 --- a/CHANGES +++ b/CHANGES @@ -3110,3 +3110,7 @@ * mail: update haraka to 3.1.2 * csp/robots: add common patterns +[9.1.0] +* acme: ARI support . https://www.rfc-editor.org/rfc/rfc9773.txt + + diff --git a/dashboard/src/utils.js b/dashboard/src/utils.js index 1fb1c990e..6d184f3d6 100644 --- a/dashboard/src/utils.js +++ b/dashboard/src/utils.js @@ -385,7 +385,9 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') { return 'Cloudron Footer set to ' + data.footer; case ACTION_CERTIFICATE_NEW: - return 'Certificate installation for ' + data.domain + (errorMessage ? ' failed' : ' succeeded'); + details = 'Certificate installation for ' + data.domain + (errorMessage ? ' failed' : ' succeeded'); + if (data.renewalInfo) details += `. Recommended renewal time is between ${data.renewalInfo.start} and ${data.renewalInfo.end}`; + return details; case ACTION_CERTIFICATE_RENEWAL: return 'Certificate renewal for ' + data.domain + (errorMessage ? ' failed' : ' succeeded'); diff --git a/src/acme2.js b/src/acme2.js index eec8a179b..7eceb1cc6 100644 --- a/src/acme2.js +++ b/src/acme2.js @@ -2,6 +2,7 @@ exports = module.exports = { getCertificate, + getRenewalInfo, // testing _name: 'acme', @@ -19,7 +20,6 @@ const assert = require('node:assert'), paths = require('./paths.js'), promiseRetry = require('./promise-retry.js'), safe = require('safetydance'), - shell = require('./shell.js')('acme2'), superagent = require('@cloudron/superagent'), users = require('./users.js'); @@ -66,8 +66,8 @@ function Acme2(fqdn, domainObject, email, key, options) { } // urlsafe base64 encoding (jose) -function urlBase64Encode(string) { - return string.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +function urlBase64Encode(base64String) { + return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } function b64(str) { @@ -75,6 +75,49 @@ function b64(str) { return urlBase64Encode(buf.toString('base64')); } +// https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients#step-3-constructing-the-ari-certid +// https://www.rfc-editor.org/rfc/rfc9773.txt +async function getAriCertId(certPem) { + assert.strictEqual(typeof certPem, 'string'); + + const aki = await openssl.getAuthorityKeyId(certPem); + const serial = await openssl.getSerial(certPem); + + return b64(aki) + '.' + b64(serial); +}; + +// ARI - https://www.rfc-editor.org/rfc/rfc9773.txt . https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients#step-3-constructing-the-ari-certid +async function getRenewalInfo(certPem, renewalUrl) { + assert.strictEqual(typeof certPem, 'string'); + assert.strictEqual(typeof renewalUrl, 'string'); + + const now = new Date(); + + const ariCertId = await getAriCertId(certPem); + const response = await superagent.get(`${renewalUrl}/${ariCertId}`).timeout(30000).ok(() => true); + if (response.status !== 200) throw new BoxError(BoxError.ACME_ERROR, `Invalid response code when renewal info : ${response.status} ${response.text}`); + + const body = response.body; + if (typeof body.suggestedWindow?.start !== 'string') throw new BoxError(BoxError.ACME_ERROR, `Invalid response suggestedWindow.start : ${response.text}`); + if (typeof body.suggestedWindow?.end !== 'string') throw new BoxError(BoxError.ACME_ERROR, `Invalid response suggestedWindow.end : ${response.text}`); + const start = new Date(body.suggestedWindow.start), end = new Date(body.suggestedWindow.end); + + const retryAfter = Number.parseInt(response.headers['Retry-After'.toLocaleLowerCase()], 10); // seconds + if (!Number.isFinite(retryAfter)) throw new BoxError(BoxError.ACME_ERROR, 'Missing or invalid retry-after in response header'); + const valid = new Date(now.getTime() + retryAfter*1000); + + const rt = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); // a uniform random time in the window + + return { + start: start.toUTCString(), + end: end.toUTCString(), + rt: rt.toUTCString(), + valid: valid.toUTCString(), + url: renewalUrl, + ts: now.toUTCString() + }; +}; + Acme2.prototype.sendSignedRequest = async function (url, payload) { assert.strictEqual(typeof url, 'string'); assert.strictEqual(typeof payload, 'string'); @@ -444,7 +487,10 @@ Acme2.prototype.acmeFlow = async function () { await this.signCertificate(order.finalize, csr); const certUrl = await this.waitForOrder(orderUrl); const cert = await this.downloadCertificate(certUrl); - return { cert, csr }; + + const renewalInfo = typeof this.directory.renewalInfo === 'string' ? await getRenewalInfo(cert, this.directory.renewalInfo) : null; + + return { cert, csr, renewalInfo }; }; Acme2.prototype.loadDirectory = async function () { @@ -468,8 +514,8 @@ Acme2.prototype.getCertificate = async function () { debug(`getCertificate: start acme flow for ${this.cn} from ${this.caDirectory}`); await this.loadDirectory(); - const result = await this.acmeFlow(); - debug(`getCertificate: acme flow completed for ${this.cn}`); + const result = await this.acmeFlow(); // { key, cert, csr, renewalInfo } + debug(`getCertificate: acme flow completed for ${this.cn}. renewalInfo: ${JSON.stringify(result.renewalInfo)}`); return result; }; diff --git a/src/openssl.js b/src/openssl.js index 63eb866e9..ea8f3362b 100644 --- a/src/openssl.js +++ b/src/openssl.js @@ -13,7 +13,9 @@ exports = module.exports = { checkHost, generateDkimKey, generateDhparam, - validateCertificate + validateCertificate, + getSerial, + getAuthorityKeyId }; const assert = require('node:assert'), @@ -26,14 +28,16 @@ const assert = require('node:assert'), safe = require('safetydance'), shell = require('./shell.js')('openssl'); -async function generateKey(certName, type) { - debug(`generateKey: generating new key for ${certName} ${type}`); +async function generateKey(type) { + debug(`generateKey: generating new key for${type}`); if (type === 'rsa4096') { return await shell.spawn('openssl', ['genrsa', '4096'], { encoding: 'utf8' }); } else if (type === 'secp256r1') { return await shell.spawn('openssl', ['ecparam', '-genkey', '-name', type], { encoding: 'utf8' }); } + + throw new BoxError(BoxError.INTERNAL_ERROR, `Unhandled key type ${type}`); } async function getModulus(pem) { @@ -219,3 +223,20 @@ async function validateCertificate(subdomain, domain, certificate) { return null; } + +async function getSerial(pem) { + assert.strictEqual(typeof pem, 'string'); + + const serialOut = await shell.spawn('openssl', ['x509', '-noout', '-serial'], { encoding: 'utf8', input: pem }); + return Buffer.from(serialOut.trim().split('=')[1], 'hex'); // serial=xx +} + +async function getAuthorityKeyId(pem) { + assert.strictEqual(typeof pem, 'string'); + + const stdout = await shell.spawn('openssl', ['x509', '-noout', '-text'], { encoding: 'utf8', input: pem }); + // if there is multiple AKI, it can have "keyid:" . length is also not fixed to 59 + const akiMatch = stdout.match(/Authority Key Identifier[\s\S]*?\n\s*([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2})+)/m); + if (!akiMatch) throw new BoxError(BoxError.OPENSSL_ERROR, 'AKI not found'); + return Buffer.from(akiMatch[1].replace(/:/g, ''), 'hex'); +} diff --git a/src/reverseproxy.js b/src/reverseproxy.js index 94383d0f0..3074e2d62 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -155,13 +155,26 @@ function getAcmeCertificateNameSync(fqdn, domainObject) { } } -async function needsRenewal(cert, options) { +async function needsRenewal(cert, renewalInfo, options) { assert.strictEqual(typeof cert, 'string'); + assert.strictEqual(typeof renewalInfo, 'object'); assert.strictEqual(typeof options, 'object'); const { startDate, endDate } = await openssl.getCertificateDates(cert); const now = new Date(); + // if we have ARI respect it. this allows us to be expempt from rate limit checks when "replaces" field is set + // https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients#step-6-indicating-which-certificate-is-replaced-by-this-new-order + if (renewalInfo) { + const rt = new Date(renewalInfo.rt); + let renew = false; + if (now.getTime() >= rt.getTime()) renew = true; // renew immediately since now is in the past + else if ((now.getTime() + (24*60*60*1000)) >= rt.getTime()) renew = true; // next cron run will be in the past + debug(`needsRenewal: ${renew}. ARI ${JSON.stringify(renewalInfo)}`); + return renew; // can wait + } + + // for CAs without ARI, fallback to 1 month let isExpiring; if (options.forceRenewal) { isExpiring = (now - startDate) > (65 * 60 * 1000); // was renewed 5 minutes ago. LE backdates issue date by 1 hour for clock skew @@ -307,6 +320,25 @@ async function getKey(certName) { return await openssl.generateKey('secp256r1'); }; +async function getRenewalInfo(cert, certName) { + const renewalInfo = JSON.parse(await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.renewal`)); + if (!renewalInfo) return null; + + if (Date.now() < (new Date(renewalInfo.valid)).getTime()) return renewalInfo; // still valid + + debug(`getRenewalInfo: ${certName} refreshing`); + const [error, result] = await safe(acme2.getRenewalInfo(cert, renewalInfo.url)); + if (error) { + debug(`getRenewalInfo: ${certName} error getting renewal info`, error); + await blobs.del(`${blobs.CERT_PREFIX}-${certName}.renewal`); + } else { + debug(`getRenewalInfo: ${certName} updated: ${JSON.stringify(result)}`); + await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.renewal`, JSON.stringify(result)); + } + + return error ? null : result; +} + async function ensureCertificate(location, options, auditSource) { assert.strictEqual(typeof location, 'object'); assert.strictEqual(typeof options, 'object'); @@ -333,7 +365,9 @@ async function ensureCertificate(location, options, auditSource) { if (key && cert) { const sameProvider = await providerMatches(domainObject, cert); - const outdated = await needsRenewal(cert, options); + const renewalInfo = await getRenewalInfo(cert, certName); + const outdated = await needsRenewal(cert, renewalInfo, options); + if (sameProvider && !outdated) { debug(`ensureCertificate: ${fqdn} acme cert exists and is up to date`); return; @@ -347,10 +381,11 @@ async function ensureCertificate(location, options, auditSource) { await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.key`, key); await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.cert`, result.cert); await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.csr`, result.csr); + await blobs.setString(`${blobs.CERT_PREFIX}-${certName}.renewal`, JSON.stringify(result.renewalInfo)); debug(`ensureCertificate: error: ${error ? error.message : 'null'}`); - await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: fqdn, errorMessage: error?.message || '' })); + await safe(eventlog.add(eventlog.ACTION_CERTIFICATE_NEW, auditSource, { domain: fqdn, errorMessage: error?.message || '', renewalInfo: result.renewalInfo })); } async function writeDashboardNginxConfig(vhost, certificatePath) { @@ -560,6 +595,7 @@ async function cleanupCerts(locations, auditSource, progressCallback) { await blobs.del(`${blobs.CERT_PREFIX}-${certName}.key`); await blobs.del(`${blobs.CERT_PREFIX}-${certName}.cert`); await blobs.del(`${blobs.CERT_PREFIX}-${certName}.csr`); + await blobs.del(`${blobs.CERT_PREFIX}-${certName}.renewal`); removedCertNames.push(certName); }