2023-01-29 15:27:11 +08:00
/**
* ACME HTTP client
*/
2024-11-12 12:15:06 +08:00
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' ;
2023-01-29 15:27:11 +08:00
/**
* 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 {
2026-02-01 15:25:28 +08:00
constructor ( directoryUrl , accountKey , externalAccountBinding = { } , urlMapping = { } , logger , cacheNonce = false ) {
2023-01-29 15:27:11 +08:00
this . directoryUrl = directoryUrl ;
this . accountKey = accountKey ;
this . externalAccountBinding = externalAccountBinding ;
this . maxBadNonceRetries = 5 ;
this . jwk = null ;
2024-07-15 19:24:17 +00:00
this . directoryCache = null ;
this . directoryMaxAge = 86400 ;
this . directoryTimestamp = 0 ;
2024-07-25 10:38:45 +08:00
this . urlMapping = urlMapping ;
2026-02-01 15:25:28 +08:00
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 ;
}
2023-01-29 15:27:11 +08:00
}
/**
* 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 = { } ) {
2024-08-26 11:34:01 +08:00
if ( this . urlMapping && this . urlMapping . enabled && this . urlMapping . mappings ) {
2024-07-25 10:38:45 +08:00
// eslint-disable-next-line no-restricted-syntax
for ( const key in this . urlMapping . mappings ) {
2026-02-08 00:26:08 +08:00
const value = this . urlMapping . mappings [ key ] ;
2024-07-25 10:38:45 +08:00
if ( url . includes ( key ) ) {
2026-02-08 00:26:08 +08:00
const newUrl = url . replace ( key , value ) ;
2025-10-15 23:03:59 +08:00
this . log ( ` use reverse proxy: ${ newUrl } ` ) ;
2024-07-25 10:38:45 +08:00
url = newUrl ;
}
}
}
2023-01-29 15:27:11 +08:00
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 */
2025-10-15 23:03:59 +08:00
this . log ( ` HTTP request: ${ method } ${ url } ` ) ;
2023-01-29 15:27:11 +08:00
const resp = await axios . request ( opts ) ;
2025-10-15 23:03:59 +08:00
this . log ( ` RESP ${ resp . status } ${ method } ${ url } ` ) ;
2026-02-01 15:25:28 +08:00
const nonce = resp . headers [ 'replay-nonce' ] ;
if ( nonce ) {
//如果有nonce
this . pushNonce ( nonce ) ;
}
2023-01-29 15:27:11 +08:00
return resp ;
}
/**
2024-07-15 19:24:17 +00:00
* Get ACME provider directory
2023-01-29 15:27:11 +08:00
*
2024-02-03 19:24:11 +00:00
* https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.1
2023-01-29 15:27:11 +08:00
*
2024-07-15 19:24:17 +00:00
* @returns {Promise<object>} ACME directory contents
2023-01-29 15:27:11 +08:00
*/
async getDirectory ( ) {
2024-07-16 19:24:08 +00:00
const now = Math . floor ( Date . now ( ) / 1000 ) ;
const age = ( now - this . directoryTimestamp ) ;
2024-07-15 19:24:17 +00:00
if ( ! this . directoryCache || ( age > this . directoryMaxAge ) ) {
2025-10-15 23:03:59 +08:00
this . log ( ` Refreshing ACME directory, age: ${ age } ` ) ;
2023-01-29 15:27:11 +08:00
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' ) ;
}
2024-07-15 19:24:17 +00:00
this . directoryCache = resp . data ;
2024-07-16 19:24:08 +00:00
this . directoryTimestamp = now ;
2023-01-29 15:27:11 +08:00
}
2024-07-15 19:24:17 +00:00
return this . directoryCache ;
2023-01-29 15:27:11 +08:00
}
/**
* 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
*
2024-02-03 19:24:11 +00:00
* https://datatracker.ietf.org/doc/html/rfc8555#section-7.2
2023-01-29 15:27:11 +08:00
*
2024-07-16 19:24:08 +00:00
* @returns {Promise<string>} Nonce
2023-01-29 15:27:11 +08:00
*/
async getNonce ( ) {
2026-02-01 15:25:28 +08:00
//尝试从队列中pop一个nonce
const nonce = this . popNonce ( ) ;
if ( nonce ) {
return nonce ;
}
2023-01-29 15:27:11 +08:00
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' ) ;
}
2026-02-01 15:25:28 +08:00
if ( this . cacheNonce ) {
return this . popNonce ( ) ;
}
2023-01-29 15:27:11 +08:00
return resp . headers [ 'replay-nonce' ] ;
2026-02-01 15:25:28 +08:00
2023-01-29 15:27:11 +08:00
}
/**
* Get URL for a directory resource
*
* @param {string} resource API resource name
* @returns {Promise<string>} URL
*/
async getResourceUrl ( resource ) {
2024-07-15 19:24:17 +00:00
const dir = await this . getDirectory ( ) ;
2023-01-29 15:27:11 +08:00
2024-07-15 19:24:17 +00:00
if ( ! dir [ resource ] ) {
2026-02-08 00:26:08 +08:00
throw new Error ( ` Unable to locate API resource URL in ACME directory: " ${ resource } ",获取ACME接口地址信息失败,可能网络不稳定或该证书颁发机构服务器崩溃,目录地址: ${ this . directoryUrl } ,请测试地址是否可以正常访问并显示json格式的URL地址列表 ` ) ;
2023-01-29 15:27:11 +08:00
}
2024-07-15 19:24:17 +00:00
return dir [ resource ] ;
2023-01-29 15:27:11 +08:00
}
/**
* Get directory meta field
*
* @param {string} field Meta field name
* @returns {Promise<string|null>} Meta field value
*/
async getMetaField ( field ) {
2024-07-15 19:24:17 +00:00
const dir = await this . getDirectory ( ) ;
2023-01-29 15:27:11 +08:00
2024-07-15 19:24:17 +00:00
if ( ( 'meta' in dir ) && ( field in dir . meta ) ) {
return dir . meta [ field ] ;
2023-01-29 15:27:11 +08:00
}
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 ) {
2025-10-15 23:03:59 +08:00
this . log ( ` Using nonce: ${ nonce } ` ) ;
2023-01-29 15:27:11 +08:00
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' ) : '' ,
2024-05-22 19:24:07 +00:00
protected : Buffer . from ( JSON . stringify ( header ) ) . toString ( 'base64url' ) ,
2023-01-29 15:27:11 +08:00
} ;
}
/**
* 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 ,
2024-05-22 19:24:07 +00:00
dsaEncoding : 'ieee-p1363' ,
2023-01-29 15:27:11 +08:00
} , 'base64url' ) ;
return result ;
}
/**
* Signed HTTP request
*
2024-02-03 19:24:11 +00:00
* https://datatracker.ietf.org/doc/html/rfc8555#section-6.2
2023-01-29 15:27:11 +08:00
*
* @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 } ) ;
2024-05-22 19:24:07 +00:00
/* Retry on bad nonce - https://datatracker.ietf.org/doc/html/rfc8555#section-6.5 */
2023-01-29 15:27:11 +08:00
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 ;
2025-10-15 23:03:59 +08:00
this . log ( ` Caught invalid nonce error, retrying ( ${ attempts } / ${ this . maxBadNonceRetries } ) signed request to: ${ url } ` ) ;
2023-01-29 15:27:11 +08:00
return this . signedRequest ( url , payload , { kid , nonce , includeExternalAccountBinding } , attempts ) ;
}
/* Return response */
return resp ;
}
}
/* Export client */
2024-11-12 12:15:06 +08:00
export default HttpClient ;