mirror of
https://github.com/certd/certd.git
synced 2026-05-16 13:17:29 +08:00
refactor(acme-client): 将acme-client改造成ts包并优化项目结构
重构acme-client模块,将原有JavaScript代码迁移至TypeScript 添加类型定义文件(.d.ts)和类型检查 更新构建配置和脚本以支持TypeScript编译 优化项目目录结构和模块导出方式 更新相关依赖和开发工具配置
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user