diff --git a/packages/certd/src/acme.js b/packages/certd/src/acme.js index 9f153c00..e4438d12 100644 --- a/packages/certd/src/acme.js +++ b/packages/certd/src/acme.js @@ -1,4 +1,4 @@ -import acme from 'acme-client' +import acme from './acme/index.cjs' import log from './utils/util.log.js' import _ from 'lodash' import sleep from './utils/util.sleep.js' diff --git a/packages/certd/src/acme/api.cjs b/packages/certd/src/acme/api.cjs new file mode 100644 index 00000000..f80ab48d --- /dev/null +++ b/packages/certd/src/acme/api.cjs @@ -0,0 +1,227 @@ +/** + * ACME API client + */ + +const util = require('./util.cjs') + +/** + * AcmeApi + * + * @class + * @param {HttpClient} httpClient + */ + +class AcmeApi { + constructor (httpClient, accountUrl = null) { + this.http = httpClient + this.accountUrl = accountUrl + } + + /** + * Get account URL + * + * @private + * @returns {string} Account URL + */ + + getAccountUrl () { + if (!this.accountUrl) { + throw new Error('No account URL found, register account first') + } + + return this.accountUrl + } + + /** + * ACME API request + * + * @private + * @param {string} url Request URL + * @param {object} [payload] Request payload, default: `null` + * @param {array} [validStatusCodes] Array of valid HTTP response status codes, default: `[]` + * @param {boolean} [jwsKid] Use KID in JWS header, default: `true` + * @returns {Promise} HTTP response + */ + + async apiRequest (url, payload = null, validStatusCodes = [], jwsKid = true) { + const kid = jwsKid ? this.getAccountUrl() : null + const resp = await this.http.signedRequest(url, payload, kid) + + if (validStatusCodes.length && (validStatusCodes.indexOf(resp.status) === -1)) { + throw new Error(util.formatResponseError(resp)) + } + + return resp + } + + /** + * ACME API request by resource name helper + * + * @private + * @param {string} resource Request resource name + * @param {object} [payload] Request payload, default: `null` + * @param {array} [validStatusCodes] Array of valid HTTP response status codes, default: `[]` + * @param {boolean} [jwsKid] Use KID in JWS header, default: `true` + * @returns {Promise} HTTP response + */ + + async apiResourceRequest (resource, payload = null, validStatusCodes = [], jwsKid = true) { + const resourceUrl = await this.http.getResourceUrl(resource) + return this.apiRequest(resourceUrl, payload, validStatusCodes, jwsKid) + } + + /** + * Get Terms of Service URL if available + * + * https://tools.ietf.org/html/rfc8555#section-7.1.1 + * + * @returns {Promise} ToS URL + */ + + async getTermsOfServiceUrl () { + return this.http.getMetaField('termsOfService') + } + + /** + * Update account + * + * https://tools.ietf.org/html/rfc8555#section-7.3.2 + * + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + + updateAccount (data) { + return this.apiRequest(this.getAccountUrl(), data, [200, 202]) + } + + /** + * Create new account + * + * https://tools.ietf.org/html/rfc8555#section-7.3 + * + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + + async createAccount (data) { + const resp = await this.apiResourceRequest('newAccount', data, [200, 201], false) + + /* Set account URL */ + if (resp.headers.location) { + this.accountUrl = resp.headers.location + } + + return resp + } + + /** + * Update account key + * + * https://tools.ietf.org/html/rfc8555#section-7.3.5 + * + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + + updateAccountKey (data) { + return this.apiResourceRequest('keyChange', data, [200]) + } + + /** + * Create new order + * + * https://tools.ietf.org/html/rfc8555#section-7.4 + * + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + + createOrder (data) { + return this.apiResourceRequest('newOrder', data, [201]) + } + + /** + * Get order + * + * https://tools.ietf.org/html/rfc8555#section-7.4 + * + * @param {string} url Order URL + * @returns {Promise} HTTP response + */ + + getOrder (url) { + return this.apiRequest(url, null, [200]) + } + + /** + * Finalize order + * + * https://tools.ietf.org/html/rfc8555#section-7.4 + * + * @param {string} url Finalization URL + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + + finalizeOrder (url, data) { + return this.apiRequest(url, data, [200]) + } + + /** + * Get identifier authorization + * + * https://tools.ietf.org/html/rfc8555#section-7.5 + * + * @param {string} url Authorization URL + * @returns {Promise} HTTP response + */ + + getAuthorization (url) { + return this.apiRequest(url, null, [200]) + } + + /** + * Update identifier authorization + * + * https://tools.ietf.org/html/rfc8555#section-7.5.2 + * + * @param {string} url Authorization URL + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + + updateAuthorization (url, data) { + return this.apiRequest(url, data, [200]) + } + + /** + * Complete challenge + * + * https://tools.ietf.org/html/rfc8555#section-7.5.1 + * + * @param {string} url Challenge URL + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + + completeChallenge (url, data) { + return this.apiRequest(url, data, [200]) + } + + /** + * Revoke certificate + * + * https://tools.ietf.org/html/rfc8555#section-7.6 + * + * @param {object} data Request payload + * @returns {Promise} HTTP response + */ + + revokeCert (data) { + return this.apiResourceRequest('revokeCert', data, [200]) + } +} + +/* Export API */ +module.exports = AcmeApi diff --git a/packages/certd/src/acme/auto.cjs b/packages/certd/src/acme/auto.cjs new file mode 100644 index 00000000..62ee540d --- /dev/null +++ b/packages/certd/src/acme/auto.cjs @@ -0,0 +1,145 @@ +/** + * ACME auto helper + */ +const Promise = require('bluebird') +const debug = require('debug')('acme-client') +const forge = require('./crypto/forge.cjs') + +const defaultOpts = { + csr: null, + email: null, + preferredChain: null, + termsOfServiceAgreed: false, + skipChallengeVerification: false, + challengePriority: ['http-01', 'dns-01'], + challengeCreateFn: async () => { throw new Error('Missing challengeCreateFn()') }, + challengeRemoveFn: async () => { throw new Error('Missing challengeRemoveFn()') } +} + +/** + * ACME client auto mode + * + * @param {AcmeClient} client ACME client + * @param {object} userOpts Options + * @returns {Promise} Certificate + */ + +module.exports = async function (client, userOpts) { + const opts = Object.assign({}, defaultOpts, userOpts) + const accountPayload = { termsOfServiceAgreed: opts.termsOfServiceAgreed } + + if (!Buffer.isBuffer(opts.csr)) { + opts.csr = Buffer.from(opts.csr) + } + + if (opts.email) { + accountPayload.contact = [`mailto:${opts.email}`] + } + + /** + * Register account + */ + + debug('[auto] Checking account') + + try { + client.getAccountUrl() + debug('[auto] Account URL already exists, skipping account registration') + } catch (e) { + debug('[auto] Registering account') + await client.createAccount(accountPayload) + } + + /** + * Parse domains from CSR + */ + + debug('[auto] Parsing domains from Certificate Signing Request') + const csrDomains = await forge.readCsrDomains(opts.csr) + const domains = [csrDomains.commonName].concat(csrDomains.altNames) + + debug(`[auto] Resolved ${domains.length} domains from parsing the Certificate Signing Request`) + + /** + * Place order + */ + + debug('[auto] Placing new certificate order with ACME provider') + const orderPayload = { identifiers: domains.map((d) => ({ type: 'dns', value: d })) } + const order = await client.createOrder(orderPayload) + const authorizations = await client.getAuthorizations(order) + + debug(`[auto] Placed certificate order successfully, received ${authorizations.length} identity authorizations`) + + /** + * Resolve and satisfy challenges + */ + + debug('[auto] Resolving and satisfying authorization challenges') + + async function challengeHandle (authz) { + const d = authz.identifier.value + + /* Select challenge based on priority */ + const challenge = authz.challenges.sort((a, b) => { + const aidx = opts.challengePriority.indexOf(a.type) + const bidx = opts.challengePriority.indexOf(b.type) + + if (aidx === -1) return 1 + if (bidx === -1) return -1 + return aidx - bidx + }).slice(0, 1)[0] + + if (!challenge) { + throw new Error(`Unable to select challenge for ${d}, no challenge found`) + } + + debug(`[auto] [${d}] Found ${authz.challenges.length} challenges, selected type: ${challenge.type}`) + + /* Trigger challengeCreateFn() */ + debug(`[auto] [${d}] Trigger challengeCreateFn()`) + const keyAuthorization = await client.getChallengeKeyAuthorization(challenge) + + try { + await opts.challengeCreateFn(authz, challenge, keyAuthorization) + + /* Challenge verification */ + if (opts.skipChallengeVerification === true) { + debug(`[auto] [${d}] Skipping challenge verification since skipChallengeVerification=true`) + } else { + debug(`[auto] [${d}] Running challenge verification`) + await client.verifyChallenge(authz, challenge) + } + + /* Complete challenge and wait for valid status */ + debug(`[auto] [${d}] Completing challenge with ACME provider and waiting for valid status`) + await client.completeChallenge(challenge) + await client.waitForValidStatus(challenge) + } finally { + /* Trigger challengeRemoveFn(), suppress errors */ + debug(`[auto] [${d}] Trigger challengeRemoveFn()`) + + try { + await opts.challengeRemoveFn(authz, challenge, keyAuthorization) + } catch (e) { + debug(`[auto] [${d}] challengeRemoveFn threw error: ${e.message}`) + } + } + } + + debug('[auto] Waiting for challenge valid status') + // await Promise.all(challengePromises) + for (const authz of authorizations) { + debug('------------------------------------------------') + await challengeHandle(authz) + await Promise.delay(1000) + debug('------------------------------------------------') + } + /** + * Finalize order and download certificate + */ + + debug('[auto] Finalizing order and downloading certificate') + await client.finalizeOrder(order, opts.csr) + return client.getCertificate(order, opts.preferredChain) +} diff --git a/packages/certd/src/acme/axios.cjs b/packages/certd/src/acme/axios.cjs new file mode 100644 index 00000000..1218733b --- /dev/null +++ b/packages/certd/src/acme/axios.cjs @@ -0,0 +1,37 @@ +/** + * Axios instance + */ + +const axios = require('axios') +const adapter = require('axios/lib/adapters/http') +const pkg = require('../../package.json') + +/** + * Instance + */ + +const instance = axios.create() + +/* Default User-Agent */ +instance.defaults.headers.common['User-Agent'] = `node-${pkg.name}/${pkg.version}` + +/* Default ACME settings */ +instance.defaults.acmeSettings = { + httpChallengePort: 80, + bypassCustomDnsResolver: false +} + +/** + * Explicitly set Node as default HTTP adapter + * + * https://github.com/axios/axios/issues/1180 + * https://stackoverflow.com/questions/42677387 + */ + +instance.defaults.adapter = adapter + +/** + * Export instance + */ + +module.exports = instance diff --git a/packages/certd/src/acme/client.cjs b/packages/certd/src/acme/client.cjs new file mode 100644 index 00000000..7f1f45d8 --- /dev/null +++ b/packages/certd/src/acme/client.cjs @@ -0,0 +1,690 @@ +/** + * ACME client + * + * @namespace Client + */ +const Promise = require('bluebird') +const crypto = require('crypto') +const debug = require('debug')('acme-client') +const HttpClient = require('./http.cjs') +const AcmeApi = require('./api.cjs') +const verify = require('./verify.cjs') +const util = require('./util.cjs') +const auto = require('./auto.cjs') +const forge = require('./crypto/forge.cjs') + +/** + * Default options + */ + +const defaultOpts = { + directoryUrl: undefined, + accountKey: undefined, + accountUrl: null, + 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 {number} [opts.backoffAttempts] Maximum number of backoff attempts, default: `5` + * @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: 5, + * backoffMin: 5000, + * backoffMax: 30000 + * }); + * ``` + */ + +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.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 */ + debug('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) { + debug('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) { + debug('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 new JWK */ + data.account = accountUrl + data.oldKey = await this.http.getJwk() + + /* TODO: Backward-compatibility with draft-ietf-acme-12, remove this in a later release */ + data.newKey = await newHttpClient.getJwk() + + /* Get signed request body from new client */ + const url = await newHttpClient.getResourceUrl('keyChange') + const body = await 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 body = forge.getPemBody(csr) + const data = { csr: util.b64escape(body) } + + 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.map((order.authorizations || []), 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 = await this.http.getJwk() + const keysum = crypto.createHash('sha256').update(JSON.stringify(jwk)) + const thumbprint = util.b64escape(keysum.digest('base64')) + 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 = crypto.createHash('sha256').update(result) + return util.b64escape(shasum.digest('base64')) + } + + 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) + } + + debug('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 */ + debug(`Item has status: ${resp.data.status}`) + + if (resp.data.status === 'invalid') { + abort() + throw new Error(util.formatResponseError(resp)) + } else if (resp.data.status === 'pending') { + throw new Error('Operation is pending') + } else if (resp.data.status === 'valid') { + return resp.data + } + + throw new Error(`Unexpected item status: ${resp.data.status}`) + } + + debug(`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 (order.status !== 'valid') { + 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.map(alternateLinks, 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 = {}) { + const body = forge.getPemBody(cert) + data.certificate = util.b64escape(body) + + 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.forge.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.forge.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 diff --git a/packages/certd/src/acme/crypto/forge.cjs b/packages/certd/src/acme/crypto/forge.cjs new file mode 100644 index 00000000..90cce27c --- /dev/null +++ b/packages/certd/src/acme/crypto/forge.cjs @@ -0,0 +1,445 @@ +/** + * node-forge crypto engine + * + * @namespace forge + */ + +const net = require('net'); +const Promise = require('bluebird'); +const forge = require('node-forge'); + +const generateKeyPair = Promise.promisify(forge.pki.rsa.generateKeyPair); + + +/** + * Attempt to parse forge object from PEM encoded string + * + * @private + * @param {string} input PEM string + * @return {object} + */ + +function forgeObjectFromPem(input) { + const msg = forge.pem.decode(input)[0]; + let result; + + switch (msg.type) { + case 'PRIVATE KEY': + case 'RSA PRIVATE KEY': + result = forge.pki.privateKeyFromPem(input); + break; + + case 'PUBLIC KEY': + case 'RSA PUBLIC KEY': + result = forge.pki.publicKeyFromPem(input); + break; + + case 'CERTIFICATE': + case 'X509 CERTIFICATE': + case 'TRUSTED CERTIFICATE': + result = forge.pki.certificateFromPem(input).publicKey; + break; + + case 'CERTIFICATE REQUEST': + result = forge.pki.certificationRequestFromPem(input).publicKey; + break; + + default: + throw new Error('Unable to detect forge message type'); + } + + return result; +} + + +/** + * Parse domain names from a certificate or CSR + * + * @private + * @param {object} obj Forge certificate or CSR + * @returns {object} {commonName, altNames} + */ + +function parseDomains(obj) { + let commonName = null; + let altNames = []; + let altNamesDict = []; + + const commonNameObject = (obj.subject.attributes || []).find((a) => a.name === 'commonName'); + const rootAltNames = (obj.extensions || []).find((e) => 'altNames' in e); + const rootExtensions = (obj.attributes || []).find((a) => 'extensions' in a); + + if (rootAltNames && rootAltNames.altNames && rootAltNames.altNames.length) { + altNamesDict = rootAltNames.altNames; + } + else if (rootExtensions && rootExtensions.extensions && rootExtensions.extensions.length) { + const extAltNames = rootExtensions.extensions.find((e) => 'altNames' in e); + + if (extAltNames && extAltNames.altNames && extAltNames.altNames.length) { + altNamesDict = extAltNames.altNames; + } + } + + if (commonNameObject) { + commonName = commonNameObject.value; + } + + if (altNamesDict) { + altNames = altNamesDict.map((a) => a.value); + } + + return { + commonName, + altNames + }; +} + + +/** + * Generate a private RSA key + * + * @param {number} [size] Size of the key, default: `2048` + * @returns {Promise} PEM encoded private RSA key + * + * @example Generate private RSA key + * ```js + * const privateKey = await acme.forge.createPrivateKey(); + * ``` + * + * @example Private RSA key with defined size + * ```js + * const privateKey = await acme.forge.createPrivateKey(4096); + * ``` + */ + +async function createPrivateKey(size = 2048) { + const keyPair = await generateKeyPair({ bits: size }); + const pemKey = forge.pki.privateKeyToPem(keyPair.privateKey); + return Buffer.from(pemKey); +} + +exports.createPrivateKey = createPrivateKey; + + +/** + * Create public key from a private RSA key + * + * @param {buffer|string} key PEM encoded private RSA key + * @returns {Promise} PEM encoded public RSA key + * + * @example Create public key + * ```js + * const publicKey = await acme.forge.createPublicKey(privateKey); + * ``` + */ + +exports.createPublicKey = async function(key) { + const privateKey = forge.pki.privateKeyFromPem(key); + const publicKey = forge.pki.rsa.setPublicKey(privateKey.n, privateKey.e); + const pemKey = forge.pki.publicKeyToPem(publicKey); + return Buffer.from(pemKey); +}; + + +/** + * Parse body of PEM encoded object form buffer or string + * If multiple objects are chained, the first body will be returned + * + * @param {buffer|string} str PEM encoded buffer or string + * @returns {string} PEM body + */ + +exports.getPemBody = (str) => { + const msg = forge.pem.decode(str)[0]; + return forge.util.encode64(msg.body); +}; + + +/** + * Split chain of PEM encoded objects from buffer or string into array + * + * @param {buffer|string} str PEM encoded buffer or string + * @returns {string[]} Array of PEM bodies + */ + +exports.splitPemChain = (str) => forge.pem.decode(str).map(forge.pem.encode); + + +/** + * Get modulus + * + * @param {buffer|string} input PEM encoded private key, certificate or CSR + * @returns {Promise} Modulus + * + * @example Get modulus + * ```js + * const m1 = await acme.forge.getModulus(privateKey); + * const m2 = await acme.forge.getModulus(certificate); + * const m3 = await acme.forge.getModulus(certificateRequest); + * ``` + */ + +exports.getModulus = async function(input) { + if (!Buffer.isBuffer(input)) { + input = Buffer.from(input); + } + + const obj = forgeObjectFromPem(input); + return Buffer.from(forge.util.hexToBytes(obj.n.toString(16)), 'binary'); +}; + + +/** + * Get public exponent + * + * @param {buffer|string} input PEM encoded private key, certificate or CSR + * @returns {Promise} Exponent + * + * @example Get public exponent + * ```js + * const e1 = await acme.forge.getPublicExponent(privateKey); + * const e2 = await acme.forge.getPublicExponent(certificate); + * const e3 = await acme.forge.getPublicExponent(certificateRequest); + * ``` + */ + +exports.getPublicExponent = async function(input) { + if (!Buffer.isBuffer(input)) { + input = Buffer.from(input); + } + + const obj = forgeObjectFromPem(input); + return Buffer.from(forge.util.hexToBytes(obj.e.toString(16)), 'binary'); +}; + + +/** + * Read domains from a Certificate Signing Request + * + * @param {buffer|string} csr PEM encoded Certificate Signing Request + * @returns {Promise} {commonName, altNames} + * + * @example Read Certificate Signing Request domains + * ```js + * const { commonName, altNames } = await acme.forge.readCsrDomains(certificateRequest); + * + * console.log(`Common name: ${commonName}`); + * console.log(`Alt names: ${altNames.join(', ')}`); + * ``` + */ + +exports.readCsrDomains = async function(csr) { + if (!Buffer.isBuffer(csr)) { + csr = Buffer.from(csr); + } + + const obj = forge.pki.certificationRequestFromPem(csr); + return parseDomains(obj); +}; + + +/** + * Read information from a certificate + * + * @param {buffer|string} cert PEM encoded certificate + * @returns {Promise} Certificate info + * + * @example Read certificate information + * ```js + * const info = await acme.forge.readCertificateInfo(certificate); + * const { commonName, altNames } = info.domains; + * + * console.log(`Not after: ${info.notAfter}`); + * console.log(`Not before: ${info.notBefore}`); + * + * console.log(`Common name: ${commonName}`); + * console.log(`Alt names: ${altNames.join(', ')}`); + * ``` + */ + +exports.readCertificateInfo = async function(cert) { + if (!Buffer.isBuffer(cert)) { + cert = Buffer.from(cert); + } + + const obj = forge.pki.certificateFromPem(cert); + const issuerCn = (obj.issuer.attributes || []).find((a) => a.name === 'commonName'); + + return { + issuer: { + commonName: issuerCn ? issuerCn.value : null + }, + domains: parseDomains(obj), + notAfter: obj.validity.notAfter, + notBefore: obj.validity.notBefore + }; +}; + + +/** + * Determine ASN.1 type for CSR subject short name + * Note: https://tools.ietf.org/html/rfc5280 + * + * @private + * @param {string} shortName CSR subject short name + * @returns {forge.asn1.Type} ASN.1 type + */ + +function getCsrValueTagClass(shortName) { + switch (shortName) { + case 'C': + return forge.asn1.Type.PRINTABLESTRING; + case 'E': + return forge.asn1.Type.IA5STRING; + default: + return forge.asn1.Type.UTF8; + } +} + + +/** + * Create array of short names and values for Certificate Signing Request subjects + * + * @private + * @param {object} subjectObj Key-value of short names and values + * @returns {object[]} Certificate Signing Request subject array + */ + +function createCsrSubject(subjectObj) { + return Object.entries(subjectObj).reduce((result, [shortName, value]) => { + if (value) { + const valueTagClass = getCsrValueTagClass(shortName); + result.push({ shortName, value, valueTagClass }); + } + + return result; + }, []); +} + + +/** + * Create array of alt names for Certificate Signing Requests + * Note: https://github.com/digitalbazaar/forge/blob/dfdde475677a8a25c851e33e8f81dca60d90cfb9/lib/x509.js#L1444-L1454 + * + * @private + * @param {string[]} altNames Alt names + * @returns {object[]} Certificate Signing Request alt names array + */ + +function formatCsrAltNames(altNames) { + return altNames.map((value) => { + const type = net.isIP(value) ? 7 : 2; + return { type, value }; + }); +} + + +/** + * Create a Certificate Signing Request + * + * @param {object} data + * @param {number} [data.keySize] Size of newly created private key, default: `2048` + * @param {string} [data.commonName] + * @param {array} [data.altNames] default: `[]` + * @param {string} [data.country] + * @param {string} [data.state] + * @param {string} [data.locality] + * @param {string} [data.organization] + * @param {string} [data.organizationUnit] + * @param {string} [data.emailAddress] + * @param {buffer|string} [key] CSR private key + * @returns {Promise} [privateKey, certificateSigningRequest] + * + * @example Create a Certificate Signing Request + * ```js + * const [certificateKey, certificateRequest] = await acme.forge.createCsr({ + * commonName: 'test.example.com' + * }); + * ``` + * + * @example Certificate Signing Request with both common and alternative names + * ```js + * const [certificateKey, certificateRequest] = await acme.forge.createCsr({ + * keySize: 4096, + * commonName: 'test.example.com', + * altNames: ['foo.example.com', 'bar.example.com'] + * }); + * ``` + * + * @example Certificate Signing Request with additional information + * ```js + * const [certificateKey, certificateRequest] = await acme.forge.createCsr({ + * commonName: 'test.example.com', + * country: 'US', + * state: 'California', + * locality: 'Los Angeles', + * organization: 'The Company Inc.', + * organizationUnit: 'IT Department', + * emailAddress: 'contact@example.com' + * }); + * ``` + * + * @example Certificate Signing Request with predefined private key + * ```js + * const certificateKey = await acme.forge.createPrivateKey(); + * + * const [, certificateRequest] = await acme.forge.createCsr({ + * commonName: 'test.example.com' + * }, certificateKey); + */ + +exports.createCsr = async function(data, key = null) { + if (!key) { + key = await createPrivateKey(data.keySize); + } + else if (!Buffer.isBuffer(key)) { + key = Buffer.from(key); + } + + if (typeof data.altNames === 'undefined') { + data.altNames = []; + } + + const csr = forge.pki.createCertificationRequest(); + + /* Public key */ + const privateKey = forge.pki.privateKeyFromPem(key); + const publicKey = forge.pki.rsa.setPublicKey(privateKey.n, privateKey.e); + csr.publicKey = publicKey; + + /* Ensure subject common name is present in SAN - https://cabforum.org/wp-content/uploads/BRv1.2.3.pdf */ + if (data.commonName && !data.altNames.includes(data.commonName)) { + data.altNames.unshift(data.commonName); + } + + /* Subject */ + const subject = createCsrSubject({ + CN: data.commonName, + C: data.country, + ST: data.state, + L: data.locality, + O: data.organization, + OU: data.organizationUnit, + E: data.emailAddress + }); + + csr.setSubject(subject); + + /* SAN extension */ + if (data.altNames.length) { + csr.setAttributes([{ + name: 'extensionRequest', + extensions: [{ + name: 'subjectAltName', + altNames: formatCsrAltNames(data.altNames) + }] + }]); + } + + /* Sign CSR */ + csr.sign(privateKey); + + /* Done */ + const pemCsr = forge.pki.certificationRequestToPem(csr); + return [key, Buffer.from(pemCsr)]; +}; diff --git a/packages/certd/src/acme/http.cjs b/packages/certd/src/acme/http.cjs new file mode 100644 index 00000000..14c915cb --- /dev/null +++ b/packages/certd/src/acme/http.cjs @@ -0,0 +1,228 @@ +/** + * ACME HTTP client + */ + +const crypto = require('crypto') +const debug = require('debug')('acme-client') +const axios = require('./axios.cjs') +const util = require('./util.cjs') +const forge = require('./crypto/forge.cjs') + +/** + * ACME HTTP client + * + * @class + * @param {string} directoryUrl ACME directory URL + * @param {buffer} accountKey PEM encoded account private key + */ + +class HttpClient { + constructor (directoryUrl, accountKey) { + this.directoryUrl = directoryUrl + this.accountKey = accountKey + + this.maxBadNonceRetries = 5 + this.directory = null + this.jwk = null + } + + /** + * HTTP request + * + * @param {string} url HTTP URL + * @param {string} method HTTP method + * @param {object} [opts] Request options + * @returns {Promise} HTTP response + */ + + async request (url, method, opts = {}) { + opts.url = url + opts.method = method + opts.validateStatus = null + + /* Headers */ + if (typeof opts.headers === 'undefined') { + opts.headers = {} + } + + opts.headers['Content-Type'] = 'application/jose+json' + + /* Request */ + debug(`HTTP request: ${method} ${url}`) + const resp = await axios.request(opts) + + debug(`RESP ${resp.status} ${method} ${url}`) + return resp + } + + /** + * Ensure provider directory exists + * + * https://tools.ietf.org/html/rfc8555#section-7.1.1 + * + * @returns {Promise} + */ + + async getDirectory () { + if (!this.directory) { + const resp = await this.request(this.directoryUrl, 'get') + this.directory = resp.data + } + } + + /** + * Get JSON Web Key + * + * @returns {Promise} {e, kty, n} + */ + + async getJwk () { + if (this.jwk) { + return this.jwk + } + + const exponent = await forge.getPublicExponent(this.accountKey) + const modulus = await forge.getModulus(this.accountKey) + + this.jwk = { + e: util.b64encode(exponent), + kty: 'RSA', + n: util.b64encode(modulus) + } + + return this.jwk + } + + /** + * Get nonce from directory API endpoint + * + * https://tools.ietf.org/html/rfc8555#section-7.2 + * + * @returns {Promise} nonce + */ + + async getNonce () { + const url = await this.getResourceUrl('newNonce') + const resp = await this.request(url, 'head') + + if (!resp.headers['replay-nonce']) { + throw new Error('Failed to get nonce from ACME provider') + } + + return resp.headers['replay-nonce'] + } + + /** + * Get URL for a directory resource + * + * @param {string} resource API resource name + * @returns {Promise} URL + */ + + async getResourceUrl (resource) { + await this.getDirectory() + + if (!this.directory[resource]) { + throw new Error(`Could not resolve URL for API resource: "${resource}"`) + } + + return this.directory[resource] + } + + /** + * Get directory meta field + * + * @param {string} field Meta field name + * @returns {Promise} Meta field value + */ + + async getMetaField (field) { + await this.getDirectory() + + if (('meta' in this.directory) && (field in this.directory.meta)) { + return this.directory.meta[field] + } + + return null + } + + /** + * Create signed HTTP request body + * + * @param {string} url Request URL + * @param {object} payload Request payload + * @param {string} [nonce] Request nonce + * @param {string} [kid] Request KID + * @returns {Promise} Signed HTTP request body + */ + + async createSignedBody (url, payload = null, nonce = null, kid = null) { + /* JWS header */ + const header = { + url, + alg: 'RS256' + } + + if (nonce) { + debug(`Using nonce: ${nonce}`) + header.nonce = nonce + } + + /* KID or JWK */ + if (kid) { + header.kid = kid + } else { + header.jwk = await this.getJwk() + } + + /* Request payload */ + const result = { + payload: payload ? util.b64encode(JSON.stringify(payload)) : '', + protected: util.b64encode(JSON.stringify(header)) + } + + /* Signature */ + const signer = crypto.createSign('RSA-SHA256').update(`${result.protected}.${result.payload}`, 'utf8') + result.signature = util.b64escape(signer.sign(this.accountKey, 'base64')) + + return result + } + + /** + * Signed HTTP request + * + * https://tools.ietf.org/html/rfc8555#section-6.2 + * + * @param {string} url Request URL + * @param {object} payload Request payload + * @param {string} [kid] Request KID + * @param {string} [nonce] Request anti-replay nonce + * @param {number} [attempts] Request attempt counter + * @returns {Promise} HTTP response + */ + + async signedRequest (url, payload, kid = null, nonce = null, attempts = 0) { + if (!nonce) { + nonce = await this.getNonce() + } + + /* Sign body and send request */ + const data = await this.createSignedBody(url, payload, nonce, kid) + const resp = await this.request(url, 'post', { data }) + + /* Retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 */ + if (resp.data && resp.data.type && (resp.status === 400) && (resp.data.type === 'urn:ietf:params:acme:error:badNonce') && (attempts < this.maxBadNonceRetries)) { + const newNonce = resp.headers['replay-nonce'] || null + attempts += 1 + + debug(`Caught invalid nonce error, retrying (${attempts}/${this.maxBadNonceRetries}) signed request to: ${url}`) + return this.signedRequest(url, payload, kid, newNonce, attempts) + } + + /* Return response */ + return resp + } +} + +/* Export client */ +module.exports = HttpClient diff --git a/packages/certd/src/acme/index.cjs b/packages/certd/src/acme/index.cjs new file mode 100644 index 00000000..e79a9210 --- /dev/null +++ b/packages/certd/src/acme/index.cjs @@ -0,0 +1,29 @@ +process.env.DEBUG = '*' +/** + * acme-client + */ + +exports.Client = require('./client.cjs') + +/** + * Directory URLs + */ + +exports.directory = { + letsencrypt: { + staging: 'https://acme-staging-v02.api.letsencrypt.org/directory', + production: 'https://acme-v02.api.letsencrypt.org/directory' + } +} + +/** + * Crypto + */ + +exports.forge = require('./crypto/forge.cjs') + +/** + * Axios + */ + +exports.axios = require('./axios.cjs') diff --git a/packages/certd/src/acme/util.cjs b/packages/certd/src/acme/util.cjs new file mode 100644 index 00000000..1d8c3ff7 --- /dev/null +++ b/packages/certd/src/acme/util.cjs @@ -0,0 +1,158 @@ +/** + * Utility methods + */ +const Promise = require('bluebird') +const Backoff = require('backo2') +const debug = require('debug')('acme-client') +const forge = require('./crypto/forge.cjs') + +/** + * Retry promise + * + * @param {function} fn Function returning promise that should be retried + * @param {number} attempts Maximum number of attempts + * @param {Backoff} backoff Backoff instance + * @returns {Promise} + */ + +async function retryPromise (fn, attempts, backoff) { + let aborted = false + + try { + const data = await fn(() => { aborted = true }) + return data + } catch (e) { + if (aborted || ((backoff.attempts + 1) >= attempts)) { + throw e + } + + const duration = backoff.duration() + debug(`Promise rejected attempt #${backoff.attempts}, retrying in ${duration}ms: ${e.message}`) + + await Promise.delay(duration) + return retryPromise(fn, attempts, backoff) + } +} + +/** + * Retry promise + * + * @param {function} fn Function returning promise that should be retried + * @param {object} [backoffOpts] Backoff options + * @param {number} [backoffOpts.attempts] Maximum number of attempts, default: `5` + * @param {number} [backoffOpts.min] Minimum attempt delay in milliseconds, default: `5000` + * @param {number} [backoffOpts.max] Maximum attempt delay in milliseconds, default: `30000` + * @returns {Promise} + */ + +function retry (fn, { attempts = 5, min = 5000, max = 30000 } = {}) { + const backoff = new Backoff({ min, max }) + return retryPromise(fn, attempts, backoff) +} + +/** + * Escape base64 encoded string + * + * @param {string} str Base64 encoded string + * @returns {string} Escaped string + */ + +function b64escape (str) { + return str.replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +/** + * Base64 encode and escape buffer or string + * + * @param {buffer|string} str Buffer or string to be encoded + * @returns {string} Escaped base64 encoded string + */ + +function b64encode (str) { + const buf = Buffer.isBuffer(str) ? str : Buffer.from(str) + return b64escape(buf.toString('base64')) +} + +/** + * Parse URLs from link header + * + * @param {string} header Link header contents + * @param {string} rel Link relation, default: `alternate` + * @returns {array} Array of URLs + */ + +function parseLinkHeader (header, rel = 'alternate') { + const relRe = new RegExp(`\\s*rel\\s*=\\s*"?${rel}"?`, 'i') + + const results = (header || '').split(/,\s* { + const [, linkUrl, linkParts] = link.match(/]*)>;(.*)/) || [] + return (linkUrl && linkParts && linkParts.match(relRe)) ? linkUrl : null + }) + + return results.filter((r) => r) +} + +/** + * Find certificate chain with preferred issuer + * If issuer can not be located, the first certificate will be returned + * + * @param {array} certificates Array of PEM encoded certificate chains + * @param {string} issuer Preferred certificate issuer + * @returns {Promise} PEM encoded certificate chain + */ + +async function findCertificateChainForIssuer (chains, issuer) { + try { + return await Promise.any(chains.map(async (chain) => { + /* Look up all issuers */ + const certs = forge.splitPemChain(chain) + const infoCollection = await Promise.map(certs, forge.readCertificateInfo) + const issuerCollection = infoCollection.map((i) => i.issuer.commonName) + + /* Found match, return it */ + if (issuerCollection.includes(issuer)) { + debug(`Found matching certificate for preferred issuer="${issuer}", issuers=${JSON.stringify(issuerCollection)}`) + return chain + } + + /* No match, throw error */ + debug(`Unable to match certificate for preferred issuer="${issuer}", issuers=${JSON.stringify(issuerCollection)}`) + throw new Error('Certificate issuer mismatch') + })) + } catch (e) { + /* No certificates matched, return default */ + debug(`Found no match in ${chains.length} certificate chains for preferred issuer="${issuer}", returning default certificate chain`) + return chains[0] + } +} + +/** + * Find and format error in response object + * + * @param {object} resp HTTP response + * @returns {string} Error message + */ + +function formatResponseError (resp) { + let result + + if (resp.data.error) { + result = resp.data.error.detail || resp.data.error + } else { + result = resp.data.detail || JSON.stringify(resp.data) + } + + return result.replace(/\n/g, '') +} + +/* Export utils */ +module.exports = { + retry, + b64escape, + b64encode, + parseLinkHeader, + findCertificateChainForIssuer, + formatResponseError +} diff --git a/packages/certd/src/acme/verify.cjs b/packages/certd/src/acme/verify.cjs new file mode 100644 index 00000000..b4fef4da --- /dev/null +++ b/packages/certd/src/acme/verify.cjs @@ -0,0 +1,90 @@ +/** + * ACME challenge verification + */ + +const Promise = require('bluebird') +const dns = Promise.promisifyAll(require('dns')) +const debug = require('debug')('acme-client') +const axios = require('./axios.cjs') + +/** + * Verify ACME HTTP challenge + * + * https://tools.ietf.org/html/rfc8555#section-8.3 + * + * @param {object} authz Identifier authorization + * @param {object} challenge Authorization challenge + * @param {string} keyAuthorization Challenge key authorization + * @param {string} [suffix] URL suffix + * @returns {Promise} + */ + +async function verifyHttpChallenge (authz, challenge, keyAuthorization, suffix = `/.well-known/acme-challenge/${challenge.token}`) { + const httpPort = axios.defaults.acmeSettings.httpChallengePort || 80 + const challengeUrl = `http://${authz.identifier.value}:${httpPort}${suffix}` + + debug(`Sending HTTP query to ${authz.identifier.value}, suffix: ${suffix}, port: ${httpPort}`) + const resp = await axios.get(challengeUrl) + const data = (resp.data || '').replace(/\s+$/, '') + + debug(`Query successful, HTTP status code: ${resp.status}`) + + if (!data || (data !== keyAuthorization)) { + throw new Error(`Authorization not found in HTTP response from ${authz.identifier.value}`) + } + + debug(`Key authorization match for ${challenge.type}/${authz.identifier.value}, ACME challenge verified`) + return true +} + +/** + * Verify ACME DNS challenge + * + * https://tools.ietf.org/html/rfc8555#section-8.4 + * + * @param {object} authz Identifier authorization + * @param {object} challenge Authorization challenge + * @param {string} keyAuthorization Challenge key authorization + * @param {string} [prefix] DNS prefix + * @returns {Promise} + */ + +async function verifyDnsChallenge (authz, challenge, keyAuthorization, prefix = '_acme-challenge.') { + debug(`Resolving DNS TXT records for ${authz.identifier.value}, prefix: ${prefix}`) + let challengeRecord = `${prefix}${authz.identifier.value}` + + try { + /* Attempt CNAME record first */ + debug(`Checking CNAME for record ${challengeRecord}`) + const cnameRecords = await dns.resolveCnameAsync(challengeRecord) + + if (cnameRecords.length) { + debug(`CNAME found at ${challengeRecord}, new challenge record: ${cnameRecords[0]}`) + challengeRecord = cnameRecords[0] + } + } catch (e) { + debug(`No CNAME found for record ${challengeRecord}`) + } + + /* Read TXT record */ + const result = await dns.resolveTxtAsync(challengeRecord) + const records = [].concat(...result) + + debug(`Query successful, found ${records.length} DNS TXT records`) + + if (records.indexOf(keyAuthorization) === -1) { + throw new Error(`Authorization not found in DNS TXT records for ${authz.identifier.value}`) + } + + debug(`Key authorization match for ${challenge.type}/${authz.identifier.value}, ACME challenge verified`) + return true +} + +/** + * Export API + */ + +module.exports = { + 'http-01': verifyHttpChallenge, + 'dns-01': verifyDnsChallenge +} diff --git a/packages/certd/src/index.js b/packages/certd/src/index.js index 5053f10f..e3e13201 100644 --- a/packages/certd/src/index.js +++ b/packages/certd/src/index.js @@ -7,6 +7,7 @@ import _ from 'lodash' import fs from 'fs' import util from './utils/util.js' import forge from 'node-forge' +process.env.DEBUG = '*' export class Certd { constructor () { this.store = new FileStore() diff --git a/packages/certd/test/index.test.js b/packages/certd/test/index.test.js index 173a3895..7ef7870c 100644 --- a/packages/certd/test/index.test.js +++ b/packages/certd/test/index.test.js @@ -8,18 +8,18 @@ describe('Certd', function () { const certd = new Certd() const rootDir = certd.buildCertDir('xiaojunnuo@qq.com', options.cert.domains) console.log('rootDir', rootDir) - expect(rootDir).match(/xiaojunnuo@qq.com\\cert\\(.*)\\(.*)/) + expect(rootDir).match(/xiaojunnuo@qq.com\\certs\\(.*)/) }) it('#writeCert', async function () { const certd = new Certd() certd.writeCert('xiaojunnuo@qq.com', ['*.domain.cn'], { csr: 'csr', crt: 'aaa', key: 'bbb' }) }) it('#certApply', async function () { - this.timeout(80000) + this.timeout(300000) const certd = new Certd() const cert = await certd.certApply(options) expect(cert).ok - expect(cert.cert).ok + expect(cert.crt).ok expect(cert.key).to.be.ok }) diff --git a/packages/certd/test/options.js b/packages/certd/test/options.js index d6dbbf76..74101ab9 100644 --- a/packages/certd/test/options.js +++ b/packages/certd/test/options.js @@ -17,7 +17,7 @@ const defaultOptions = { } }, cert: { - domains: ['*.docmirror.cn', 'docmirror.cn'], + domains: ['*.docmirror.club', 'docmirror.club'], email: 'xiaojunnuo@qq.com', challenge: { challengeType: 'dns',