acme: ARI support

ARI is a hint from the cert issuer about when to renew a cert. We will
use it when the API is available.

It provides a mechanim for CAs to revoke certs and signal to clients
that cert should be renewed.

https://www.rfc-editor.org/rfc/rfc9773.txt
https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients
This commit is contained in:
Girish Ramakrishnan
2026-01-17 22:31:36 +01:00
parent f65b33f3fc
commit 6877dfb772
5 changed files with 122 additions and 13 deletions

View File

@@ -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);
}