diff --git a/src/auto.js b/src/auto.js index 9f63587..1a52a43 100644 --- a/src/auto.js +++ b/src/auto.js @@ -10,6 +10,7 @@ const defaultOpts = { email: null, preferredChain: null, termsOfServiceAgreed: false, + replacesCertificateId: null, skipChallengeVerification: false, challengePriority: ['http-01', 'dns-01'], challengeCreateFn: async () => { throw new Error('Missing challengeCreateFn()'); }, @@ -67,6 +68,12 @@ module.exports = async (client, userOpts) => { log('[auto] Placing new certificate order with ACME provider'); const orderPayload = { identifiers: uniqueDomains.map((d) => ({ type: 'dns', value: d })) }; + + if (opts.replacesCertificateId) { + log(`[auto] Replacing certificate with ID ${opts.replacesCertificateId}`); + orderPayload.replaces = opts.replacesCertificateId; + } + const order = await client.createOrder(orderPayload); const authorizations = await client.getAuthorizations(order); diff --git a/src/client.js b/src/client.js index 728fa07..7c90bfc 100644 --- a/src/client.js +++ b/src/client.js @@ -738,6 +738,7 @@ class AcmeClient { * @param {function} opts.challengeRemoveFn Function returning Promise triggered after completing ACME challenge * @param {string} [opts.email] Account email address * @param {boolean} [opts.termsOfServiceAgreed] Agree to Terms of Service, default: `false` + * @param {string} [opts.replacesCertificateId] Certificate ID to replace when renewing using ARI * @param {boolean} [opts.skipChallengeVerification] Skip internal challenge verification before notifying ACME provider, default: `false` * @param {string[]} [opts.challengePriority] Array defining challenge type priority, default: `['http-01', 'dns-01']` * @param {string} [opts.preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null` @@ -777,6 +778,28 @@ class AcmeClient { * challengeRemoveFn: async () => {}, * }); * ``` + * + * @example Renew an existing certificate using ARI (ACME Renewal Information) + * ```js + * const certificate = { ... }; // Previously issued certificate + * const ariCertId = acme.crypto.getAriCertificateId(certificate); + * const waitSeconds = await client.getSecondsUntilCertificateRenewable(certId); + * + * if (waitSeconds === 0) { + * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({ + * altNames: ['test.example.com'], + * }); + * + * const renewedCertificate = await client.auto({ + * csr: certificateRequest, + * email: 'test@example.com', + * termsOfServiceAgreed: true, + * replacesCertificateId: ariCertId, + * challengeCreateFn: async () => {}, + * challengeRemoveFn: async () => {}, + * }); + * } + * ``` */ auto(opts) { diff --git a/test/70-auto.spec.js b/test/70-auto.spec.js index 8e6f0eb..157d388 100644 --- a/test/70-auto.spec.js +++ b/test/70-auto.spec.js @@ -337,6 +337,10 @@ describe('client.auto', () => { assert.isString(cert); }); + /** + * Alternate certificate chains + */ + it('should order alternate certificate chain [ACME_CAP_ALTERNATE_CERT_ROOTS]', async function () { if (!capAlternateCertRoots) { this.skip(); @@ -385,6 +389,27 @@ describe('client.auto', () => { assert.strictEqual(testIssuers[0], info.issuer.commonName); }); + /** + * Renew certificate using ACME Renewal Information (ARI) + */ + + it('should renew certificate using ari', async () => { + const [, csr] = await acme.crypto.createCsr({ + commonName: testDomain, + }, await createKeyFn()); + + const cert = await testClient.auto({ + csr, + termsOfServiceAgreed: true, + replacesCertificateId: acme.crypto.getAriCertificateId(testCertificate), + challengeCreateFn: cts.challengeCreateFn, + challengeRemoveFn: cts.challengeRemoveFn, + }); + + assert.isString(cert); + assert.notStrictEqual(cert, testCertificate); + }); + /** * Order certificate with alternate key sizes */