mirror of
https://github.com/certd/certd.git
synced 2026-04-05 07:20:56 +08:00
375 lines
11 KiB
JavaScript
375 lines
11 KiB
JavaScript
/**
|
||
* ACME HTTP client
|
||
*/
|
||
import { createHmac, createSign, constants } from 'crypto';
|
||
const { RSA_PKCS1_PADDING } = constants;
|
||
import axios from './axios.js';
|
||
import { log } from './logger.js';
|
||
import { getJwk } from './crypto/index.js';
|
||
|
||
/**
|
||
* ACME HTTP client
|
||
*
|
||
* @class
|
||
* @param {string} directoryUrl ACME directory URL
|
||
* @param {buffer} accountKey PEM encoded account private key
|
||
* @param {object} [opts.externalAccountBinding]
|
||
* @param {string} [opts.externalAccountBinding.kid] External account binding KID
|
||
* @param {string} [opts.externalAccountBinding.hmacKey] External account binding HMAC key
|
||
*/
|
||
|
||
class HttpClient {
|
||
constructor(directoryUrl, accountKey, externalAccountBinding = {}, urlMapping = {}, logger, cacheNonce= false) {
|
||
this.directoryUrl = directoryUrl;
|
||
this.accountKey = accountKey;
|
||
this.externalAccountBinding = externalAccountBinding;
|
||
|
||
this.maxBadNonceRetries = 5;
|
||
this.jwk = null;
|
||
|
||
this.directoryCache = null;
|
||
this.directoryMaxAge = 86400;
|
||
this.directoryTimestamp = 0;
|
||
this.urlMapping = urlMapping;
|
||
this.log = logger ? logger.info.bind(logger) : log;
|
||
this.nonces = [];
|
||
this.cacheNonce = cacheNonce;
|
||
}
|
||
|
||
pushNonce(nonce) {
|
||
if (!this.cacheNonce || !nonce) {
|
||
return;
|
||
}
|
||
this.nonces.push({
|
||
nonce,
|
||
expires: Date.now() + 30*1000,
|
||
});
|
||
}
|
||
popNonce() {
|
||
while (true) {
|
||
if (this.nonces.length === 0) {
|
||
return null;
|
||
}
|
||
const item = this.nonces.shift();
|
||
if (!item) {
|
||
return null;
|
||
}
|
||
if (item.expires < Date.now()) {
|
||
continue;
|
||
}
|
||
return item.nonce;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 = {}) {
|
||
if (this.urlMapping && this.urlMapping.enabled && this.urlMapping.mappings) {
|
||
// eslint-disable-next-line no-restricted-syntax
|
||
for (const key in this.urlMapping.mappings) {
|
||
const value = this.urlMapping.mappings[key];
|
||
if (url.includes(key)) {
|
||
const newUrl = url.replace(key, value);
|
||
this.log(`use reverse proxy: ${newUrl}`);
|
||
url = newUrl;
|
||
}
|
||
}
|
||
}
|
||
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 */
|
||
this.log(`HTTP request: ${method} ${url}`);
|
||
const resp = await axios.request(opts);
|
||
|
||
this.log(`RESP ${resp.status} ${method} ${url}`);
|
||
|
||
const nonce = resp.headers['replay-nonce'];
|
||
if (nonce) {
|
||
//如果有nonce
|
||
this.pushNonce(nonce);
|
||
}
|
||
|
||
return resp;
|
||
}
|
||
|
||
/**
|
||
* Get ACME provider directory
|
||
*
|
||
* https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.1
|
||
*
|
||
* @returns {Promise<object>} ACME directory contents
|
||
*/
|
||
|
||
async getDirectory() {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const age = (now - this.directoryTimestamp);
|
||
|
||
if (!this.directoryCache || (age > this.directoryMaxAge)) {
|
||
this.log(`Refreshing ACME directory, age: ${age}`);
|
||
const resp = await this.request(this.directoryUrl, 'get');
|
||
|
||
if (resp.status >= 400) {
|
||
throw new Error(`Attempting to read ACME directory returned error ${resp.status}: ${this.directoryUrl}`);
|
||
}
|
||
|
||
if (!resp.data) {
|
||
throw new Error('Attempting to read ACME directory returned no data');
|
||
}
|
||
|
||
this.directoryCache = resp.data;
|
||
this.directoryTimestamp = now;
|
||
}
|
||
|
||
return this.directoryCache;
|
||
}
|
||
|
||
/**
|
||
* Get JSON Web Key
|
||
*
|
||
* @returns {object} JSON Web Key
|
||
*/
|
||
|
||
getJwk() {
|
||
if (!this.jwk) {
|
||
this.jwk = getJwk(this.accountKey);
|
||
}
|
||
|
||
return this.jwk;
|
||
}
|
||
|
||
/**
|
||
* Get nonce from directory API endpoint
|
||
*
|
||
* https://datatracker.ietf.org/doc/html/rfc8555#section-7.2
|
||
*
|
||
* @returns {Promise<string>} Nonce
|
||
*/
|
||
|
||
async getNonce() {
|
||
|
||
//尝试从队列中pop一个nonce
|
||
const nonce = this.popNonce();
|
||
if (nonce) {
|
||
return nonce;
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
if (this.cacheNonce) {
|
||
return this.popNonce();
|
||
}
|
||
return resp.headers['replay-nonce'];
|
||
|
||
}
|
||
|
||
/**
|
||
* Get URL for a directory resource
|
||
*
|
||
* @param {string} resource API resource name
|
||
* @returns {Promise<string>} URL
|
||
*/
|
||
|
||
async getResourceUrl(resource) {
|
||
const dir = await this.getDirectory();
|
||
|
||
if (!dir[resource]) {
|
||
throw new Error(`Unable to locate API resource URL in ACME directory: "${resource}",获取ACME接口地址信息失败,可能网络不稳定或该证书颁发机构服务器崩溃,目录地址:${this.directoryUrl},请测试地址是否可以正常访问并显示json格式的URL地址列表`);
|
||
}
|
||
|
||
return dir[resource];
|
||
}
|
||
|
||
/**
|
||
* Get directory meta field
|
||
*
|
||
* @param {string} field Meta field name
|
||
* @returns {Promise<string|null>} Meta field value
|
||
*/
|
||
|
||
async getMetaField(field) {
|
||
const dir = await this.getDirectory();
|
||
|
||
if (('meta' in dir) && (field in dir.meta)) {
|
||
return dir.meta[field];
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Prepare HTTP request body for signature
|
||
*
|
||
* @param {string} alg JWS algorithm
|
||
* @param {string} url Request URL
|
||
* @param {object} [payload] Request payload
|
||
* @param {object} [opts]
|
||
* @param {string} [opts.nonce] JWS anti-replay nonce
|
||
* @param {string} [opts.kid] JWS KID
|
||
* @returns {object} Signed HTTP request body
|
||
*/
|
||
|
||
prepareSignedBody(alg, url, payload = null, { nonce = null, kid = null } = {}) {
|
||
const header = { alg, url };
|
||
|
||
/* Nonce */
|
||
if (nonce) {
|
||
this.log(`Using nonce: ${nonce}`);
|
||
header.nonce = nonce;
|
||
}
|
||
|
||
/* KID or JWK */
|
||
if (kid) {
|
||
header.kid = kid;
|
||
}
|
||
else {
|
||
header.jwk = this.getJwk();
|
||
}
|
||
|
||
/* Body */
|
||
return {
|
||
payload: payload ? Buffer.from(JSON.stringify(payload)).toString('base64url') : '',
|
||
protected: Buffer.from(JSON.stringify(header)).toString('base64url'),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Create JWS HTTP request body using HMAC
|
||
*
|
||
* @param {string} hmacKey HMAC key
|
||
* @param {string} url Request URL
|
||
* @param {object} [payload] Request payload
|
||
* @param {object} [opts]
|
||
* @param {string} [opts.nonce] JWS anti-replay nonce
|
||
* @param {string} [opts.kid] JWS KID
|
||
* @returns {object} Signed HMAC request body
|
||
*/
|
||
|
||
createSignedHmacBody(hmacKey, url, payload = null, { nonce = null, kid = null } = {}) {
|
||
const result = this.prepareSignedBody('HS256', url, payload, { nonce, kid });
|
||
|
||
/* Signature */
|
||
const signer = createHmac('SHA256', Buffer.from(hmacKey, 'base64')).update(`${result.protected}.${result.payload}`, 'utf8');
|
||
result.signature = signer.digest().toString('base64url');
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Create JWS HTTP request body using RSA or ECC
|
||
*
|
||
* https://datatracker.ietf.org/doc/html/rfc7515
|
||
*
|
||
* @param {string} url Request URL
|
||
* @param {object} [payload] Request payload
|
||
* @param {object} [opts]
|
||
* @param {string} [opts.nonce] JWS nonce
|
||
* @param {string} [opts.kid] JWS KID
|
||
* @returns {object} JWS request body
|
||
*/
|
||
|
||
createSignedBody(url, payload = null, { nonce = null, kid = null } = {}) {
|
||
const jwk = this.getJwk();
|
||
let headerAlg = 'RS256';
|
||
let signerAlg = 'SHA256';
|
||
|
||
/* https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 */
|
||
if (jwk.crv && (jwk.kty === 'EC')) {
|
||
headerAlg = 'ES256';
|
||
|
||
if (jwk.crv === 'P-384') {
|
||
headerAlg = 'ES384';
|
||
signerAlg = 'SHA384';
|
||
}
|
||
else if (jwk.crv === 'P-521') {
|
||
headerAlg = 'ES512';
|
||
signerAlg = 'SHA512';
|
||
}
|
||
}
|
||
|
||
/* Prepare body and signer */
|
||
const result = this.prepareSignedBody(headerAlg, url, payload, { nonce, kid });
|
||
const signer = createSign(signerAlg).update(`${result.protected}.${result.payload}`, 'utf8');
|
||
|
||
/* Signature - https://stackoverflow.com/questions/39554165 */
|
||
result.signature = signer.sign({
|
||
key: this.accountKey,
|
||
padding: RSA_PKCS1_PADDING,
|
||
dsaEncoding: 'ieee-p1363',
|
||
}, 'base64url');
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Signed HTTP request
|
||
*
|
||
* https://datatracker.ietf.org/doc/html/rfc8555#section-6.2
|
||
*
|
||
* @param {string} url Request URL
|
||
* @param {object} payload Request payload
|
||
* @param {object} [opts]
|
||
* @param {string} [opts.kid] JWS KID
|
||
* @param {string} [opts.nonce] JWS anti-replay nonce
|
||
* @param {boolean} [opts.includeExternalAccountBinding] Include EAB in request
|
||
* @param {number} [attempts] Request attempt counter
|
||
* @returns {Promise<object>} HTTP response
|
||
*/
|
||
|
||
async signedRequest(url, payload, { kid = null, nonce = null, includeExternalAccountBinding = false } = {}, attempts = 0) {
|
||
if (!nonce) {
|
||
nonce = await this.getNonce();
|
||
}
|
||
|
||
/* External account binding */
|
||
if (includeExternalAccountBinding && this.externalAccountBinding) {
|
||
if (this.externalAccountBinding.kid && this.externalAccountBinding.hmacKey) {
|
||
const jwk = this.getJwk();
|
||
const eabKid = this.externalAccountBinding.kid;
|
||
const eabHmacKey = this.externalAccountBinding.hmacKey;
|
||
|
||
payload.externalAccountBinding = this.createSignedHmacBody(eabHmacKey, url, jwk, { kid: eabKid });
|
||
}
|
||
}
|
||
|
||
/* Sign body and send request */
|
||
const data = this.createSignedBody(url, payload, { nonce, kid });
|
||
const resp = await this.request(url, 'post', { data });
|
||
|
||
/* Retry on bad nonce - https://datatracker.ietf.org/doc/html/rfc8555#section-6.5 */
|
||
if (resp.data && resp.data.type && (resp.status === 400) && (resp.data.type === 'urn:ietf:params:acme:error:badNonce') && (attempts < this.maxBadNonceRetries)) {
|
||
nonce = resp.headers['replay-nonce'] || null;
|
||
attempts += 1;
|
||
|
||
this.log(`Caught invalid nonce error, retrying (${attempts}/${this.maxBadNonceRetries}) signed request to: ${url}`);
|
||
return this.signedRequest(url, payload, { kid, nonce, includeExternalAccountBinding }, attempts);
|
||
}
|
||
|
||
/* Return response */
|
||
return resp;
|
||
}
|
||
}
|
||
|
||
/* Export client */
|
||
export default HttpClient;
|