mirror of https://github.com/certd/certd
feat: 多域名绑定一张证书
parent
458486dd6b
commit
a4bd29e6bf
|
@ -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'
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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)];
|
||||
};
|
|
@ -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
|
|
@ -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')
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ const defaultOptions = {
|
|||
}
|
||||
},
|
||||
cert: {
|
||||
domains: ['*.docmirror.cn', 'docmirror.cn'],
|
||||
domains: ['*.docmirror.club', 'docmirror.club'],
|
||||
email: 'xiaojunnuo@qq.com',
|
||||
challenge: {
|
||||
challengeType: 'dns',
|
||||
|
|
Loading…
Reference in New Issue