feat: 多域名绑定一张证书

master
xiaojunnuo 2020-12-15 01:09:41 +08:00
parent 458486dd6b
commit a4bd29e6bf
13 changed files with 2055 additions and 5 deletions

View File

@ -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'

View File

@ -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<object>} 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<object>} 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<string|null>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} HTTP response
*/
revokeCert (data) {
return this.apiResourceRequest('revokeCert', data, [200])
}
}
/* Export API */
module.exports = AcmeApi

View File

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

View File

@ -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

View File

@ -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<string|null>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object[]>} 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<object>} 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<string>} 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<object>} 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<object>} 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<string>} 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<string>} 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

View File

@ -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<buffer>} 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<buffer>} 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<buffer>} 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<buffer>} 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<object>} {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<object>} 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<buffer[]>} [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)];
};

View File

@ -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<object>} 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<object>} {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<string>} 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<string>} 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<string|null>} 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<object>} 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<object>} 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

View File

@ -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')

View File

@ -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*</).map((link) => {
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<string>} 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
}

View File

@ -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<boolean>}
*/
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<boolean>}
*/
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
}

View File

@ -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()

View File

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

View File

@ -17,7 +17,7 @@ const defaultOptions = {
}
},
cert: {
domains: ['*.docmirror.cn', 'docmirror.cn'],
domains: ['*.docmirror.club', 'docmirror.club'],
email: 'xiaojunnuo@qq.com',
challenge: {
challengeType: 'dns',