diff --git a/src/certnode/lib/client.js b/src/certnode/lib/client.js index 59b9a00..b4285a5 100644 --- a/src/certnode/lib/client.js +++ b/src/certnode/lib/client.js @@ -1,17 +1,7 @@ -import fs from "fs"; -import path from "path"; -import {promisify} from "util"; -import {exportJWK} from "jose"; -import {generateKeyPair} from "jose"; -import {calculateJwkThumbprint} from "jose"; -import {SignJWT} from "jose"; -import {CompactSign} from "jose"; -import pem from "pem"; +import rsacsr from "rsa-csr"; +import { exportJWK, generateKeyPair, calculateJwkThumbprint, SignJWT, CompactSign } from "jose"; import * as common from "./common.js"; import request from "./request.js"; - -const createCsr = promisify(pem.createCSR); - /** * Represents a Let's Encrypt account and * sends requests to get valid TLS certificates. @@ -24,7 +14,7 @@ class Client { this.accountPrivateJwk = null; /** @type {import('crypto').KeyObject|null} */ this.accountPrivateKey = null; - /** @type {import("jose/types.js").JWK | undefined} */ + /** @type {import("jose").JWK | undefined} */ this.accountPublicJwk = undefined; /** @type {import('crypto').KeyObject|null} */ this.accountPublicKey = null; @@ -38,88 +28,81 @@ class Client { this.replayNonce = ''; this.thumbprint = ''; } - /** * Export account public and private keys to a directory. * - * @param {String} dirname - name of directory to write key files to * @param {String} [passphrase] - optional passphrase to encrypt private key with * - * @return {Promise} + * @return {{ privateKey: string, publicKey: string }} */ - exportAccountKeyPair(dirname, passphrase) { + exportAccountKeyPair(passphrase) { if (this.accountPrivateKey == null || this.accountPublicKey == null) { - return Promise.reject(new Error('Account key pair not generated')); + throw new Error('Account key pair not generated'); + } + return { + // @ts-ignore + accountPrivateKey: common.exportPrivateKey(this.accountPrivateKey, passphrase), + // @ts-ignore + accountPublicKey: common.exportPublicKey(this.accountPublicKey), } - const privateKeyFile = path.join(dirname, 'privateKey.pem'); - const publicKeyFile = path.join(dirname, 'publicKey.pem'); - return Promise.all([ - common.writeKeyToFile(privateKeyFile, this.accountPrivateKey, passphrase), - common.writeKeyToFile(publicKeyFile, this.accountPublicKey) - ]); } - /** * Generate new account public and private keys. * - * @return {Promise} + * @return {Promise} */ async generateAccountKeyPair() { - const {privateKey, publicKey} = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM); + const { privateKey, publicKey } = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM); // @ts-ignore this.accountPrivateKey = privateKey; // @ts-ignore this.accountPublicKey = publicKey; await this.initAccountJwks(); } - /** * Generate a certificate from Let's Encrypt for your domain. * * @param {String} domain - the domain you want a certificate for * - * @return {Promise} + * @return {Promise<{cert: string, key: string}>} */ async generateCertificate(domain) { await this.directory(); await this.newNonce(); if (!this.myAccountUrl) await this.newAccount(); - const {authzUrls, finalizeUrl} = await this.newOrder(domain); - const {challenge} = await this.authz(authzUrls[0]); + const { authzUrls, finalizeUrl } = await this.newOrder(domain); + const { challenge } = await this.authz(authzUrls[0]); await this.completeChallenge(challenge, domain); await this.pollAuthz(authzUrls[0]); - const {certificate, privateKeyData} = await this.finalizeOrder(finalizeUrl, domain); + const { certificate, privateKeyData } = await this.finalizeOrder(finalizeUrl, domain); return { - certificate, - privateKeyData + cert: certificate, + key: privateKeyData }; } - /** - * Import account public and private keys from a directory. - * - * @param {String} dirname - name of directory to read key files from - * @param {String} [passphrase] - optional passphrase to decrypt private key with - * - * @return {Promise} + * Import account from PEM public and private keys. + * @param {string} privateKey + * @param {string} publicKey + * @param {string | undefined} [passphrase] + * @return {Promise} */ - async importAccountKeyPair(dirname, passphrase) { - const [privateKeyData, publicKeyData] = await Promise.all([ - fs.promises.readFile(path.join(dirname, 'privateKey.pem'), 'utf8'), - fs.promises.readFile(path.join(dirname, 'publicKey.pem'), 'utf8') - ]); - this.accountPrivateKey = common.importPrivateKey(privateKeyData, passphrase); - this.accountPublicKey = common.importPublicKey(publicKeyData); + async importAccountKeyPair(privateKey, publicKey, passphrase) { + this.accountPrivateKey = common.importPrivateKey(privateKey, passphrase); + this.accountPublicKey = common.importPublicKey(publicKey); await this.initAccountJwks(); } - + /** + * @param {string | URL} authzUrl + */ async authz(authzUrl) { const data = await this.sign({ kid: this.myAccountUrl, nonce: this.replayNonce, url: authzUrl }); + /** @type {import("./request.js").Response<{challenges: {type: string}[], identifier: {value: string}, status: string}>} */ const res = await request(authzUrl, { method: 'POST', headers: { @@ -131,20 +114,22 @@ class Client { if (res.statusCode !== 200) { throw new Error(`authz() Status Code: ${res.statusCode} Data: ${res.data}`); } - const {challenges, identifier, ...rest} = res.data; - const challenge = challenges.find(({type}) => type === 'http-01'); + const { challenges, identifier, ...rest } = res.data; + const challenge = challenges.find(({ type }) => type === 'http-01'); return { challenge, domain: identifier.value, ...rest }; } - + /** + * @param {any} challenge + * @param {string} domain + */ async completeChallenge(challenge, domain) { await this.readyChallenge(challenge); await this.receiveServerRequest(challenge, domain); } - async directory() { if (this.hasDirectory) return false; @@ -158,7 +143,9 @@ class Client { this.newOrderUrl = res.data.newOrder; return true; } - + /** + * @param {string | URL} certificateUrl + */ async fetchCertificate(certificateUrl) { const data = await this.sign({ kid: this.myAccountUrl, @@ -180,17 +167,20 @@ class Client { return res.data; } + /** + * @param {any} finalizeUrl + * @param {string} domain + */ async finalizeOrder(finalizeUrl, domain) { - const {privateKey} = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM); + const { privateKey } = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM); // @ts-ignore - const clientKey = common.exportPrivateKey(privateKey); - let { - csr - // @ts-ignore - } = await createCsr({ - clientKey, - commonName: domain, + const privateKeyData = common.exportPrivateKey(privateKey); + + let csr = await rsacsr({ + key: await exportJWK(privateKey), + domains: [domain], }); + // "The CSR is sent in the base64url-encoded version of the DER format. // (Note: Because this field uses base64url, and does not include headers, // it is different from PEM.)" @@ -202,31 +192,48 @@ class Client { .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); - const data = await this.sign({ - kid: this.myAccountUrl, - nonce: this.replayNonce, - url: finalizeUrl - }, { - csr - }); - const res = await request(finalizeUrl, { - method: 'POST', - headers: { - 'content-type': 'application/jose+json' - }, - data - }); - this.setReplayNonce(res); + + const sendFinalizeRequest = async (/** @type {string | URL} */ finalizeUrl, /** @type {import("jose").JWTPayload | undefined} */ payload) => { + const data = await this.sign({ + kid: this.myAccountUrl, + nonce: this.replayNonce, + url: finalizeUrl + }, payload); + + const res = await request(finalizeUrl, { + method: 'POST', + headers: { + 'content-type': 'application/jose+json' + }, + data + }); + this.setReplayNonce(res); + return res; + } + let res = await sendFinalizeRequest(finalizeUrl, { csr }); + // Let's encrypt actually want this to work! + // https://community.letsencrypt.org/t/enabling-asynchronous-order-finalization/193522 + while (res.data.status === 'processing') { + let retryUrl = res.headers.location || ''; + let retryTime = parseInt(res.headers["retry-after"] || '1') * 1000 + // sleep, retry + await new Promise(resolve => setTimeout(resolve, retryTime)) + res = await sendFinalizeRequest(retryUrl, undefined); + if (res.data.status == "ready") { + res = await sendFinalizeRequest(res.data.finalize, undefined); + break + } + } if (res.statusCode !== 200) { throw new Error(`finalizeOrder() Status Code: ${res.statusCode} Data: ${res.data}`); } const certificate = await this.fetchCertificate(res.data.certificate); return { certificate, - privateKeyData: clientKey + privateKeyData }; - } + } async initAccountJwks() { if (this.accountPrivateKey == null || this.accountPublicKey == null) { return Promise.reject(new Error('Account key pair not generated')); @@ -239,7 +246,9 @@ class Client { this.accountPrivateJwk = accountPrivateJwk; this.thumbprint = await calculateJwkThumbprint(publicJwk); } - + /** + * @param {undefined[]} emails + */ async newAccount(...emails) { const data = await this.sign({ jwk: this.accountPublicJwk, @@ -259,10 +268,9 @@ class Client { if (![200, 201].includes(res.statusCode)) { throw new Error(`newAccount() Status Code: ${res.statusCode} Data: ${res.data}`); } - this.myAccountUrl = res.headers.location; + this.myAccountUrl = res.headers.location || ""; return res.statusCode === 201; } - async newNonce() { if (this.replayNonce) return false; @@ -275,7 +283,9 @@ class Client { this.setReplayNonce(res); return true; } - + /** + * @param {string[]} domains + */ async newOrder(...domains) { const identifiers = domains.map(domain => ({ type: 'dns', @@ -300,7 +310,7 @@ class Client { throw new Error(`newOrder() Status Code: ${res.statusCode} Data: ${res.data}`); } const orderUrl = res.headers.location; - const {authorizations: authzUrls, finalize: finalizeUrl} = res.data; + const { authorizations: authzUrls, finalize: finalizeUrl } = res.data; return { authzUrls, domains, @@ -308,12 +318,14 @@ class Client { orderUrl }; } - + /** + * @param {any} authzUrl + */ async pollAuthz(authzUrl) { for (let i = 0; i < 10; i++) { const result = await this.authz(authzUrl); if (result.status === 'pending') { - await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise(resolve => setTimeout(resolve, 1e3)); continue; } if (result.status === 'invalid') { @@ -323,7 +335,9 @@ class Client { } throw new Error('pollAuthz() timed out'); } - + /** + * @param {{ url: string | URL; }} challenge + */ async readyChallenge(challenge) { const data = await this.sign({ kid: this.myAccountUrl, @@ -342,16 +356,19 @@ class Client { throw new Error(`readyChallenge() Status Code: ${res.statusCode} Data: ${res.data}`); } } - + /** + * @param {{ token: string; }} challenge + * @param {any} domain + */ receiveServerRequest(challenge, domain) { return new Promise((resolve, reject) => { const time = setTimeout(() => { reject(new Error('Timed out waiting for server request')); - }, 5000); + }, 10e3); let hasResolved = false; this.challengeCallbacks = () => { if (!hasResolved) - setTimeout(resolve, 5000); + setTimeout(resolve, 100); else return challenge.token + '.' + this.thumbprint; hasResolved = true; @@ -362,7 +379,9 @@ class Client { }; }); } - + /** + * @param {{ data?: any; headers: any; statusCode?: number; }} res + */ setReplayNonce(res) { const replayNonce = (res.headers['replay-nonce'] || '').trim(); if (!replayNonce) { @@ -370,10 +389,9 @@ class Client { } this.replayNonce = replayNonce; } - /** - * @param {import("jose/types.js").JWSHeaderParameters} header - * @param {import("jose/types.js").JWTPayload | undefined} [payload] + * @param {import("jose").JWSHeaderParameters} header + * @param {import("jose").JWTPayload | undefined} [payload] */ async sign(header, payload) { if (this.accountPrivateKey == null) { @@ -382,12 +400,14 @@ class Client { let data; if (payload) { data = await new SignJWT(payload) + // @ts-ignore .setProtectedHeader({ alg: common.ACCOUNT_KEY_ALGORITHM, ...header }) .sign(this.accountPrivateKey); - } else { + } + else { // SignJWT constructor only accepts object but RFC8555 requires empty payload // Workaround: manually pass empty Uint8Array to CompactSign constructor const sig = new CompactSign(new Uint8Array()); @@ -405,5 +425,4 @@ class Client { }); } } - -export default Client; +export default Client; \ No newline at end of file diff --git a/src/certnode/lib/common.js b/src/certnode/lib/common.js index 8de2111..185cd9f 100644 --- a/src/certnode/lib/common.js +++ b/src/certnode/lib/common.js @@ -1,12 +1,12 @@ -import crypto from "crypto"; -import fs from "fs"; - +import crypto from "node:crypto"; +import fs from "node:fs"; export const ACCOUNT_KEY_ALGORITHM = 'ES256'; export const CERTIFICATE_KEY_ALGORITHM = 'RS256'; -const env = (process.env.NODE_ENV || '').trim().toLowerCase(); -export const DIRECTORY_URL = ['development', 'test'].includes(env) - ? 'https://acme-staging-v02.api.letsencrypt.org/directory' - : 'https://acme-v02.api.letsencrypt.org/directory'; +export const DIRECTORY_URL = { + 'production': 'https://acme-v02.api.letsencrypt.org/directory', + 'development': 'https://acme-staging-v02.api.letsencrypt.org/directory', + 'test': 'https://localhost:14000/dir', +}[(process.env.NODE_ENV || 'development').trim().toLowerCase()] || "" export const PRIVATE_KEY_CIPHER = 'aes-256-cbc'; export const PRIVATE_KEY_FORMAT = 'pem'; export const PRIVATE_KEY_PERMISSIONS = 0o600; @@ -14,11 +14,10 @@ export const PRIVATE_KEY_TYPE = 'pkcs8'; export const PUBLIC_KEY_FORMAT = 'pem'; export const PUBLIC_KEY_PERMISSIONS = 0o666; export const PUBLIC_KEY_TYPE = 'spki'; - /** * @param {crypto.KeyObject} privateKey * @param {String} [passphrase] - * + * @returns {string} */ export const exportPrivateKey = (privateKey, passphrase) => { /** @type {crypto.KeyExportOptions<'pem'>} */ @@ -30,20 +29,21 @@ export const exportPrivateKey = (privateKey, passphrase) => { privateKeyOpts.cipher = PRIVATE_KEY_CIPHER; privateKeyOpts.passphrase = passphrase; } + // @ts-ignore return privateKey.export(privateKeyOpts); }; - /** * @param {crypto.KeyObject} publicKey + * @returns {string} */ export const exportPublicKey = publicKey => { /** @type {crypto.KeyExportOptions<'pem'>} */ + // @ts-ignore return publicKey.export({ type: PUBLIC_KEY_TYPE, format: PUBLIC_KEY_FORMAT }); }; - /** * @param {String} privateKeyData * @param {String} [passphrase] @@ -62,11 +62,11 @@ export const importPrivateKey = (privateKeyData, passphrase) => { } try { return crypto.createPrivateKey(privateKeyOpts); - } catch { + } + catch { throw new Error('Failed to import private key'); } }; - /** * @param {String} publicKeyData * @@ -79,24 +79,25 @@ export const importPublicKey = publicKeyData => { format: PUBLIC_KEY_FORMAT, type: PUBLIC_KEY_TYPE }); - } catch { + } + catch { throw new Error('Failed to import public key'); } }; - /** * @param {String} filename * @param {crypto.KeyObject|string} key * @param {String} [passphrase] * - * @return {Promise} + * @return {Promise} */ export const writeKeyToFile = async (filename, key, passphrase) => { if (typeof key === 'string') { key = key.includes('PRIVATE KEY') ? importPrivateKey(key, passphrase) : importPublicKey(key); - } else if (!(key instanceof crypto.KeyObject)) { + } + else if (!(key instanceof crypto.KeyObject)) { throw new Error('Expected "key" to be crypto.KeyObject or string'); } const isPrivateKey = key.type === 'private'; @@ -104,6 +105,5 @@ export const writeKeyToFile = async (filename, key, passphrase) => { ? exportPrivateKey(key, passphrase) : exportPublicKey(key); const mode = isPrivateKey ? PRIVATE_KEY_PERMISSIONS : PUBLIC_KEY_PERMISSIONS; - await fs.promises.writeFile(filename, keyData, {mode}); + await fs.promises.writeFile(filename, keyData, { mode }); }; - diff --git a/src/certnode/lib/request.js b/src/certnode/lib/request.js index 9a2c919..25fa608 100644 --- a/src/certnode/lib/request.js +++ b/src/certnode/lib/request.js @@ -1,20 +1,29 @@ -import https from "https"; +import http from "node:http"; +import https from "node:https"; +/** + * @template T + * @typedef {{data: T, headers: import('http').IncomingHttpHeaders, statusCode: number}} Response + */ /** + * @template T * @param {string | URL} url * @param {import('https').RequestOptions & {data?: string}} [options] - * @param {() => void} [cb] - * @return {Promise<{data: any, headers: import('http').IncomingHttpHeaders, statusCode: number}>} + * @return {Promise>} */ -const request = (url, {data = '', ...options} = {}, cb) => { +const request = (url, { data = '', ...options } = {}) => { return new Promise((resolve, reject) => { try { url = new URL(url); - } catch (err) { + } + catch (err) { return reject(err); } - https.request(url, options, res => { - const {statusCode, headers} = res; + (url.protocol == 'https:' ? https : http).request(url, options, res => { + const { statusCode, headers } = res; + /** + * @type {any} + */ let data = ''; res .on('data', chunk => { @@ -24,12 +33,13 @@ const request = (url, {data = '', ...options} = {}, cb) => { if (headers['content-type']?.includes('application/json')) { try { data = JSON.parse(data); - } catch (err) { + } + catch (err) { reject(err); return; } } - resolve({data, headers, statusCode: statusCode || 0}); + resolve({ data, headers, statusCode: statusCode || 0 }); }) .once('error', reject); }) @@ -39,7 +49,6 @@ const request = (url, {data = '', ...options} = {}, cb) => { const method = options.method || 'GET'; reject(new Error(`${method} request to "${url}" timed out`)); }, 10e3); - cb && cb(); }); }; -export default request; +export default request; \ No newline at end of file