certd/packages/core/acme-client/src/client.js

724 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* ACME client
*
* @namespace Client
*/
import { createHash } from 'crypto';
import { getPemBodyAsB64u } from './crypto/index.js';
import { log } from './logger.js';
import HttpClient from './http.js';
import AcmeApi from './api.js';
import verify from './verify.js';
import * as util from './util.js';
import auto from './auto.js';
import { CancelError } from './error.js';
/**
* ACME states
*
* @private
*/
const validStates = ['ready', 'valid'];
const pendingStates = ['pending', 'processing'];
const invalidStates = ['invalid'];
/**
* Default options
*
* @private
*/
const defaultOpts = {
directoryUrl: undefined,
accountKey: undefined,
accountUrl: null,
externalAccountBinding: {},
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 {object} [opts.externalAccountBinding]
* @param {string} [opts.externalAccountBinding.kid] External account binding KID
* @param {string} [opts.externalAccountBinding.hmacKey] External account binding HMAC key
* @param {number} [opts.backoffAttempts] Maximum number of backoff attempts, default: `10`
* @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: 10,
* backoffMin: 5000,
* backoffMax: 30000,
* });
* ```
*
* @example Create ACME client with external account binding
* ```js
* const client = new acme.Client({
* directoryUrl: 'https://acme-provider.example.com/directory-url',
* accountKey: 'Private key goes here',
* externalAccountBinding: {
* kid: 'YOUR-EAB-KID',
* hmacKey: 'YOUR-EAB-HMAC-KEY',
* },
* });
* ```
*/
class AcmeClient {
constructor(opts) {
if (!Buffer.isBuffer(opts.accountKey)) {
opts.accountKey = Buffer.from(opts.accountKey);
}
this.opts = { ...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.opts.externalAccountBinding, this.opts.urlMapping);
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://datatracker.ietf.org/doc/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 */
log('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) {
log('Account already exists (HTTP 200), returning updateAccount()');
return this.updateAccount(data);
}
return resp.data;
}
}
/**
* Update existing account
*
* https://datatracker.ietf.org/doc/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) {
log('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://datatracker.ietf.org/doc/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, this.opts.externalAccountBinding);
const newApiClient = new AcmeApi(newHttpClient, accountUrl);
/* Get old JWK */
data.account = accountUrl;
data.oldKey = this.http.getJwk();
/* Get signed request body from new client */
const url = await newHttpClient.getResourceUrl('keyChange');
const body = 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://datatracker.ietf.org/doc/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 = this.api.getLocationFromHeader(resp);
return resp.data;
}
/**
* Refresh order object from CA
*
* https://datatracker.ietf.org/doc/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://datatracker.ietf.org/doc/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 data = { csr: getPemBodyAsB64u(csr) };
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://datatracker.ietf.org/doc/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.all((order.authorizations || []).map(async (url) => {
const resp = await this.api.getAuthorization(url);
/* Add URL to response */
resp.data.url = url;
return resp.data;
}));
}
/**
* Deactivate identifier authorization
*
* https://datatracker.ietf.org/doc/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://datatracker.ietf.org/doc/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 = this.http.getJwk();
const keysum = createHash('sha256').update(JSON.stringify(jwk));
const thumbprint = keysum.digest('base64url');
const result = `${challenge.token}.${thumbprint}`;
/* https://datatracker.ietf.org/doc/html/rfc8555#section-8.3 */
if (challenge.type === 'http-01') {
return result;
}
/* https://datatracker.ietf.org/doc/html/rfc8555#section-8.4 */
if (challenge.type === 'dns-01') {
return createHash('sha256').update(result).digest('base64url');
}
/* https://datatracker.ietf.org/doc/html/rfc8737 */
if (challenge.type === 'tls-alpn-01') {
return result;
}
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 (abort) => {
if (this.opts.signal && this.opts.signal.aborted) {
abort();
throw new CancelError('用户取消');
}
await verify[challenge.type](authz, challenge, keyAuthorization);
};
log('Waiting for ACME challenge verification等待ACME挑战验证', this.backoffOpts);
return util.retry(verifyFn, this.backoffOpts);
}
/**
* Notify CA that challenge has been completed
*
* https://datatracker.ietf.org/doc/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) {
if (this.opts.signal && this.opts.signal.aborted) {
throw new CancelError('用户取消');
}
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://datatracker.ietf.org/doc/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 authorization status
* ```js
* const authz = { ... };
* await client.waitForValidStatus(authz);
* ```
*
* @example Wait for valid order status
* ```js
* const order = { ... };
* await client.waitForValidStatus(order);
* ```
*/
async waitForValidStatus(item,d) {
if (!item.url) {
throw new Error(`[${d}] Unable to verify status of item, URL not found`);
}
const verifyFn = async (abort) => {
if (this.opts.signal && this.opts.signal.aborted) {
abort();
throw new CancelError('用户取消');
}
const resp = await this.api.apiRequest(item.url, null, [200]);
/* Verify status */
log(`[${d}] Item has status挑战状态: ${resp.data.status}`);
if (invalidStates.includes(resp.data.status)) {
abort();
throw new Error(util.formatResponseError(resp));
}
else if (pendingStates.includes(resp.data.status)) {
throw new Error(`[${d}] Operation is pending or processing当前仍然在等待状态`);
}
else if (validStates.includes(resp.data.status)) {
return resp.data;
}
throw new Error(`[${d}] Unexpected item status: ${resp.data.status}`);
};
log(`[${d}] Waiting for valid status 等待valid状态: ${item.url}`, this.backoffOpts);
return util.retry(verifyFn, this.backoffOpts);
}
/**
* Get certificate from ACME order
*
* https://datatracker.ietf.org/doc/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 (!validStates.includes(order.status)) {
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.all(alternateLinks.map(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://datatracker.ietf.org/doc/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 = {}) {
data.certificate = getPemBodyAsB64u(cert);
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.crypto.createCsr({
* altNames: ['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.crypto.createCsr({
* altNames: ['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 */
export default AcmeClient;