/** * ACME client * * @namespace Client */ const { createHash } = require('crypto'); const { getPemBodyAsB64u } = require('./crypto'); const { log } = require('./logger'); const HttpClient = require('./http'); const AcmeApi = require('./api'); const verify = require('./verify'); const util = require('./util'); const auto = require('./auto'); /** * ACME states * * @private */ const validStates = ['ready', 'valid']; const pendingStates = ['pending', 'processing']; const invalidStates = ['invalid']; /** * Default options * * @private */ const defaultOpts = { directoryUrl: undefined, accountKey: undefined, accountUrl: null, externalAccountBinding: {}, backoffAttempts: 10, backoffMin: 5000, backoffMax: 30000 }; /** * AcmeClient * * @class * @param {object} opts * @param {string} opts.directoryUrl ACME directory URL * @param {buffer|string} opts.accountKey PEM encoded account private key * @param {string} [opts.accountUrl] Account URL, default: `null` * @param {object} [opts.externalAccountBinding] * @param {string} [opts.externalAccountBinding.kid] External account binding KID * @param {string} [opts.externalAccountBinding.hmacKey] External account binding HMAC key * @param {number} [opts.backoffAttempts] Maximum number of backoff attempts, default: `10` * @param {number} [opts.backoffMin] Minimum backoff attempt delay in milliseconds, default: `5000` * @param {number} [opts.backoffMax] Maximum backoff attempt delay in milliseconds, default: `30000` * * @example Create ACME client instance * ```js * const client = new acme.Client({ * directoryUrl: acme.directory.letsencrypt.staging, * accountKey: 'Private key goes here' * }); * ``` * * @example Create ACME client instance * ```js * const client = new acme.Client({ * directoryUrl: acme.directory.letsencrypt.staging, * accountKey: 'Private key goes here', * accountUrl: 'Optional account URL goes here', * backoffAttempts: 10, * backoffMin: 5000, * backoffMax: 30000 * }); * ``` * * @example Create ACME client with external account binding * ```js * const client = new acme.Client({ * directoryUrl: 'https://acme-provider.example.com/directory-url', * accountKey: 'Private key goes here', * externalAccountBinding: { * kid: 'YOUR-EAB-KID', * hmacKey: 'YOUR-EAB-HMAC-KEY' * } * }); * ``` */ class AcmeClient { constructor(opts) { if (!Buffer.isBuffer(opts.accountKey)) { opts.accountKey = Buffer.from(opts.accountKey); } this.opts = Object.assign({}, defaultOpts, opts); this.backoffOpts = { attempts: this.opts.backoffAttempts, min: this.opts.backoffMin, max: this.opts.backoffMax }; this.http = new HttpClient(this.opts.directoryUrl, this.opts.accountKey, this.opts.externalAccountBinding); this.api = new AcmeApi(this.http, this.opts.accountUrl); } /** * Get Terms of Service URL if available * * @returns {Promise} ToS URL * * @example Get Terms of Service URL * ```js * const termsOfService = client.getTermsOfServiceUrl(); * * if (!termsOfService) { * // CA did not provide Terms of Service * } * ``` */ getTermsOfServiceUrl() { return this.api.getTermsOfServiceUrl(); } /** * Get current account URL * * @returns {string} Account URL * @throws {Error} No account URL found * * @example Get current account URL * ```js * try { * const accountUrl = client.getAccountUrl(); * } * catch (e) { * // No account URL exists, need to create account first * } * ``` */ getAccountUrl() { return this.api.getAccountUrl(); } /** * Create a new account * * https://tools.ietf.org/html/rfc8555#section-7.3 * * @param {object} [data] Request data * @returns {Promise} Account * * @example Create a new account * ```js * const account = await client.createAccount({ * termsOfServiceAgreed: true * }); * ``` * * @example Create a new account with contact info * ```js * const account = await client.createAccount({ * termsOfServiceAgreed: true, * contact: ['mailto:test@example.com'] * }); * ``` */ async createAccount(data = {}) { try { this.getAccountUrl(); /* Account URL exists */ log('Account URL exists, returning updateAccount()'); return this.updateAccount(data); } catch (e) { const resp = await this.api.createAccount(data); /* HTTP 200: Account exists */ if (resp.status === 200) { log('Account already exists (HTTP 200), returning updateAccount()'); return this.updateAccount(data); } return resp.data; } } /** * Update existing account * * https://tools.ietf.org/html/rfc8555#section-7.3.2 * * @param {object} [data] Request data * @returns {Promise} Account * * @example Update existing account * ```js * const account = await client.updateAccount({ * contact: ['mailto:foo@example.com'] * }); * ``` */ async updateAccount(data = {}) { try { this.api.getAccountUrl(); } catch (e) { log('No account URL found, returning createAccount()'); return this.createAccount(data); } /* Remove data only applicable to createAccount() */ if ('onlyReturnExisting' in data) { delete data.onlyReturnExisting; } /* POST-as-GET */ if (Object.keys(data).length === 0) { data = null; } const resp = await this.api.updateAccount(data); return resp.data; } /** * Update account private key * * https://tools.ietf.org/html/rfc8555#section-7.3.5 * * @param {buffer|string} newAccountKey New PEM encoded private key * @param {object} [data] Additional request data * @returns {Promise} Account * * @example Update account private key * ```js * const newAccountKey = 'New private key goes here'; * const result = await client.updateAccountKey(newAccountKey); * ``` */ async updateAccountKey(newAccountKey, data = {}) { if (!Buffer.isBuffer(newAccountKey)) { newAccountKey = Buffer.from(newAccountKey); } const accountUrl = this.api.getAccountUrl(); /* Create new HTTP and API clients using new key */ const newHttpClient = new HttpClient(this.opts.directoryUrl, newAccountKey); const newApiClient = new AcmeApi(newHttpClient, accountUrl); /* Get old JWK */ data.account = accountUrl; data.oldKey = this.http.getJwk(); /* Get signed request body from new client */ const url = await newHttpClient.getResourceUrl('keyChange'); const body = newHttpClient.createSignedBody(url, data); /* Change key using old client */ const resp = await this.api.updateAccountKey(body); /* Replace existing HTTP and API client */ this.http = newHttpClient; this.api = newApiClient; return resp.data; } /** * Create a new order * * https://tools.ietf.org/html/rfc8555#section-7.4 * * @param {object} data Request data * @returns {Promise} Order * * @example Create a new order * ```js * const order = await client.createOrder({ * identifiers: [ * { type: 'dns', value: 'example.com' }, * { type: 'dns', value: 'test.example.com' } * ] * }); * ``` */ async createOrder(data) { const resp = await this.api.createOrder(data); if (!resp.headers.location) { throw new Error('Creating a new order did not return an order link'); } /* Add URL to response */ resp.data.url = resp.headers.location; return resp.data; } /** * Refresh order object from CA * * https://tools.ietf.org/html/rfc8555#section-7.4 * * @param {object} order Order object * @returns {Promise} Order * * @example * ```js * const order = { ... }; // Previously created order object * const result = await client.getOrder(order); * ``` */ async getOrder(order) { if (!order.url) { throw new Error('Unable to get order, URL not found'); } const resp = await this.api.getOrder(order.url); /* Add URL to response */ resp.data.url = order.url; return resp.data; } /** * Finalize order * * https://tools.ietf.org/html/rfc8555#section-7.4 * * @param {object} order Order object * @param {buffer|string} csr PEM encoded Certificate Signing Request * @returns {Promise} Order * * @example Finalize order * ```js * const order = { ... }; // Previously created order object * const csr = { ... }; // Previously created Certificate Signing Request * const result = await client.finalizeOrder(order, csr); * ``` */ async finalizeOrder(order, csr) { if (!order.finalize) { throw new Error('Unable to finalize order, URL not found'); } if (!Buffer.isBuffer(csr)) { csr = Buffer.from(csr); } const data = { csr: getPemBodyAsB64u(csr) }; const resp = await this.api.finalizeOrder(order.finalize, data); /* Add URL to response */ resp.data.url = order.url; return resp.data; } /** * Get identifier authorizations from order * * https://tools.ietf.org/html/rfc8555#section-7.5 * * @param {object} order Order * @returns {Promise} Authorizations * * @example Get identifier authorizations * ```js * const order = { ... }; // Previously created order object * const authorizations = await client.getAuthorizations(order); * * authorizations.forEach((authz) => { * const { challenges } = authz; * }); * ``` */ async getAuthorizations(order) { return Promise.all((order.authorizations || []).map(async (url) => { const resp = await this.api.getAuthorization(url); /* Add URL to response */ resp.data.url = url; return resp.data; })); } /** * Deactivate identifier authorization * * https://tools.ietf.org/html/rfc8555#section-7.5.2 * * @param {object} authz Identifier authorization * @returns {Promise} Authorization * * @example Deactivate identifier authorization * ```js * const authz = { ... }; // Identifier authorization resolved from previously created order * const result = await client.deactivateAuthorization(authz); * ``` */ async deactivateAuthorization(authz) { if (!authz.url) { throw new Error('Unable to deactivate identifier authorization, URL not found'); } const data = { status: 'deactivated' }; const resp = await this.api.updateAuthorization(authz.url, data); /* Add URL to response */ resp.data.url = authz.url; return resp.data; } /** * Get key authorization for ACME challenge * * https://tools.ietf.org/html/rfc8555#section-8.1 * * @param {object} challenge Challenge object returned by API * @returns {Promise} Key authorization * * @example Get challenge key authorization * ```js * const challenge = { ... }; // Challenge from previously resolved identifier authorization * const key = await client.getChallengeKeyAuthorization(challenge); * * // Write key somewhere to satisfy challenge * ``` */ async getChallengeKeyAuthorization(challenge) { const jwk = this.http.getJwk(); const keysum = createHash('sha256').update(JSON.stringify(jwk)); const thumbprint = keysum.digest('base64url'); const result = `${challenge.token}.${thumbprint}`; /** * https://tools.ietf.org/html/rfc8555#section-8.3 */ if (challenge.type === 'http-01') { return result; } /** * https://tools.ietf.org/html/rfc8555#section-8.4 * https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01 */ if ((challenge.type === 'dns-01') || (challenge.type === 'tls-alpn-01')) { const shasum = createHash('sha256').update(result); return shasum.digest('base64url'); } throw new Error(`Unable to produce key authorization, unknown challenge type: ${challenge.type}`); } /** * Verify that ACME challenge is satisfied * * @param {object} authz Identifier authorization * @param {object} challenge Authorization challenge * @returns {Promise} * * @example Verify satisfied ACME challenge * ```js * const authz = { ... }; // Identifier authorization * const challenge = { ... }; // Satisfied challenge * await client.verifyChallenge(authz, challenge); * ``` */ async verifyChallenge(authz, challenge) { if (!authz.url || !challenge.url) { throw new Error('Unable to verify ACME challenge, URL not found'); } if (typeof verify[challenge.type] === 'undefined') { throw new Error(`Unable to verify ACME challenge, unknown type: ${challenge.type}`); } const keyAuthorization = await this.getChallengeKeyAuthorization(challenge); const verifyFn = async () => { await verify[challenge.type](authz, challenge, keyAuthorization); }; log('Waiting for ACME challenge verification', this.backoffOpts); return util.retry(verifyFn, this.backoffOpts); } /** * Notify CA that challenge has been completed * * https://tools.ietf.org/html/rfc8555#section-7.5.1 * * @param {object} challenge Challenge object returned by API * @returns {Promise} Challenge * * @example Notify CA that challenge has been completed * ```js * const challenge = { ... }; // Satisfied challenge * const result = await client.completeChallenge(challenge); * ``` */ async completeChallenge(challenge) { const resp = await this.api.completeChallenge(challenge.url, {}); return resp.data; } /** * Wait for ACME provider to verify status on a order, authorization or challenge * * https://tools.ietf.org/html/rfc8555#section-7.5.1 * * @param {object} item An order, authorization or challenge object * @returns {Promise} Valid order, authorization or challenge * * @example Wait for valid challenge status * ```js * const challenge = { ... }; * await client.waitForValidStatus(challenge); * ``` * * @example Wait for valid authoriation status * ```js * const authz = { ... }; * await client.waitForValidStatus(authz); * ``` * * @example Wait for valid order status * ```js * const order = { ... }; * await client.waitForValidStatus(order); * ``` */ async waitForValidStatus(item) { if (!item.url) { throw new Error('Unable to verify status of item, URL not found'); } const verifyFn = async (abort) => { const resp = await this.api.apiRequest(item.url, null, [200]); /* Verify status */ log(`Item has status: ${resp.data.status}`); if (invalidStates.includes(resp.data.status)) { abort(); throw new Error(util.formatResponseError(resp)); } else if (pendingStates.includes(resp.data.status)) { throw new Error('Operation is pending or processing'); } else if (validStates.includes(resp.data.status)) { return resp.data; } throw new Error(`Unexpected item status: ${resp.data.status}`); }; log(`Waiting for valid status from: ${item.url}`, this.backoffOpts); return util.retry(verifyFn, this.backoffOpts); } /** * Get certificate from ACME order * * https://tools.ietf.org/html/rfc8555#section-7.4.2 * * @param {object} order Order object * @param {string} [preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null` * @returns {Promise} Certificate * * @example Get certificate * ```js * const order = { ... }; // Previously created order * const certificate = await client.getCertificate(order); * ``` * * @example Get certificate with preferred chain * ```js * const order = { ... }; // Previously created order * const certificate = await client.getCertificate(order, 'DST Root CA X3'); * ``` */ async getCertificate(order, preferredChain = null) { if (!validStates.includes(order.status)) { order = await this.waitForValidStatus(order); } if (!order.certificate) { throw new Error('Unable to download certificate, URL not found'); } const resp = await this.api.apiRequest(order.certificate, null, [200]); /* Handle alternate certificate chains */ if (preferredChain && resp.headers.link) { const alternateLinks = util.parseLinkHeader(resp.headers.link); const alternates = await Promise.all(alternateLinks.map(async (link) => this.api.apiRequest(link, null, [200]))); const certificates = [resp].concat(alternates).map((c) => c.data); return util.findCertificateChainForIssuer(certificates, preferredChain); } /* Return default certificate chain */ return resp.data; } /** * Revoke certificate * * https://tools.ietf.org/html/rfc8555#section-7.6 * * @param {buffer|string} cert PEM encoded certificate * @param {object} [data] Additional request data * @returns {Promise} * * @example Revoke certificate * ```js * const certificate = { ... }; // Previously created certificate * const result = await client.revokeCertificate(certificate); * ``` * * @example Revoke certificate with reason * ```js * const certificate = { ... }; // Previously created certificate * const result = await client.revokeCertificate(certificate, { * reason: 4 * }); * ``` */ async revokeCertificate(cert, data = {}) { data.certificate = getPemBodyAsB64u(cert); const resp = await this.api.revokeCert(data); return resp.data; } /** * Auto mode * * @param {object} opts * @param {buffer|string} opts.csr Certificate Signing Request * @param {function} opts.challengeCreateFn Function returning Promise triggered before completing ACME challenge * @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 {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` * @returns {Promise} Certificate * * @example Order a certificate using auto mode * ```js * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({ * commonName: 'test.example.com' * }); * * const certificate = await client.auto({ * csr: certificateRequest, * email: 'test@example.com', * termsOfServiceAgreed: true, * challengeCreateFn: async (authz, challenge, keyAuthorization) => { * // Satisfy challenge here * }, * challengeRemoveFn: async (authz, challenge, keyAuthorization) => { * // Clean up challenge here * } * }); * ``` * * @example Order a certificate using auto mode with preferred chain * ```js * const [certificateKey, certificateRequest] = await acme.crypto.createCsr({ * commonName: 'test.example.com' * }); * * const certificate = await client.auto({ * csr: certificateRequest, * email: 'test@example.com', * termsOfServiceAgreed: true, * preferredChain: 'DST Root CA X3', * challengeCreateFn: async () => {}, * challengeRemoveFn: async () => {} * }); * ``` */ auto(opts) { return auto(this, opts); } } /* Export client */ module.exports = AcmeClient;