Files
certd/packages/core/acme-client/src/http.js
2026-02-08 00:26:08 +08:00

375 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;