From f691328476ddbbfdc3a14d4c68902ae13c38d951 Mon Sep 17 00:00:00 2001 From: Pierre Lannoy Date: Sat, 25 Apr 2026 18:43:08 +0200 Subject: [PATCH] Reverts dependencies changes --- package.json | 14 +-- src/certnode/lib/client.js | 213 ++++++++++++++++-------------------- src/certnode/lib/common.js | 38 +++---- src/certnode/lib/request.js | 31 ++---- 4 files changed, 131 insertions(+), 165 deletions(-) diff --git a/package.json b/package.json index c78382f..893b540 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "domain-forward", - "version": "1.2.0", + "version": "1.1.0", "description": "Service to forward domain via Hosterra DoH", "main": "app.js", "type": "module", @@ -18,17 +18,11 @@ "dependencies": { "async-lock": "^1.4.0", "await-lock": "^2.2.2", - "base32-encode": "^1.1.1", - "dns-packet": "^5.2.1", "dotenv": "^16.0.3", - "jose": "^6.2.2", - "rsa-csr": "^1.0.6", + "jose": "^3.20.4", "pem": "^1.14.7", - "better-sqlite3": "^11.1.2", - "chokidar": "^3.5.3", - "lru-cache": "^11.0.0", - "node-forge": "^1.3.1", - "validator": "^13.9.0" + "base32-encode": "^1.1.1", + "dns-packet": "^5.2.1" }, "devDependencies": { "pm2": "^5.3.0" diff --git a/src/certnode/lib/client.js b/src/certnode/lib/client.js index b4285a5..59b9a00 100644 --- a/src/certnode/lib/client.js +++ b/src/certnode/lib/client.js @@ -1,7 +1,17 @@ -import rsacsr from "rsa-csr"; -import { exportJWK, generateKeyPair, calculateJwkThumbprint, SignJWT, CompactSign } from "jose"; +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 * 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. @@ -14,7 +24,7 @@ class Client { this.accountPrivateJwk = null; /** @type {import('crypto').KeyObject|null} */ this.accountPrivateKey = null; - /** @type {import("jose").JWK | undefined} */ + /** @type {import("jose/types.js").JWK | undefined} */ this.accountPublicJwk = undefined; /** @type {import('crypto').KeyObject|null} */ this.accountPublicKey = null; @@ -28,81 +38,88 @@ 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 {{ privateKey: string, publicKey: string }} + * @return {Promise} */ - exportAccountKeyPair(passphrase) { + exportAccountKeyPair(dirname, passphrase) { if (this.accountPrivateKey == null || this.accountPublicKey == null) { - throw new Error('Account key pair not generated'); - } - return { - // @ts-ignore - accountPrivateKey: common.exportPrivateKey(this.accountPrivateKey, passphrase), - // @ts-ignore - accountPublicKey: common.exportPublicKey(this.accountPublicKey), + return Promise.reject(new Error('Account key pair not generated')); } + 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<{cert: string, key: string}>} + * @return {Promise} */ 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 { - cert: certificate, - key: privateKeyData + certificate, + privateKeyData }; } + /** - * Import account from PEM public and private keys. - * @param {string} privateKey - * @param {string} publicKey - * @param {string | undefined} [passphrase] - * @return {Promise} + * 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} */ - async importAccountKeyPair(privateKey, publicKey, passphrase) { - this.accountPrivateKey = common.importPrivateKey(privateKey, passphrase); - this.accountPublicKey = common.importPublicKey(publicKey); + 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); 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: { @@ -114,22 +131,20 @@ 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; @@ -143,9 +158,7 @@ class Client { this.newOrderUrl = res.data.newOrder; return true; } - /** - * @param {string | URL} certificateUrl - */ + async fetchCertificate(certificateUrl) { const data = await this.sign({ kid: this.myAccountUrl, @@ -167,20 +180,17 @@ 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 privateKeyData = common.exportPrivateKey(privateKey); - - let csr = await rsacsr({ - key: await exportJWK(privateKey), - domains: [domain], + const clientKey = common.exportPrivateKey(privateKey); + let { + csr + // @ts-ignore + } = await createCsr({ + clientKey, + commonName: 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.)" @@ -192,48 +202,31 @@ class Client { .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); - - 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 - } - } + 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); 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 + privateKeyData: clientKey }; - } + async initAccountJwks() { if (this.accountPrivateKey == null || this.accountPublicKey == null) { return Promise.reject(new Error('Account key pair not generated')); @@ -246,9 +239,7 @@ class Client { this.accountPrivateJwk = accountPrivateJwk; this.thumbprint = await calculateJwkThumbprint(publicJwk); } - /** - * @param {undefined[]} emails - */ + async newAccount(...emails) { const data = await this.sign({ jwk: this.accountPublicJwk, @@ -268,9 +259,10 @@ 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; @@ -283,9 +275,7 @@ class Client { this.setReplayNonce(res); return true; } - /** - * @param {string[]} domains - */ + async newOrder(...domains) { const identifiers = domains.map(domain => ({ type: 'dns', @@ -310,7 +300,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, @@ -318,14 +308,12 @@ 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, 1e3)); + await new Promise(resolve => setTimeout(resolve, 5000)); continue; } if (result.status === 'invalid') { @@ -335,9 +323,7 @@ class Client { } throw new Error('pollAuthz() timed out'); } - /** - * @param {{ url: string | URL; }} challenge - */ + async readyChallenge(challenge) { const data = await this.sign({ kid: this.myAccountUrl, @@ -356,19 +342,16 @@ 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')); - }, 10e3); + }, 5000); let hasResolved = false; this.challengeCallbacks = () => { if (!hasResolved) - setTimeout(resolve, 100); + setTimeout(resolve, 5000); else return challenge.token + '.' + this.thumbprint; hasResolved = true; @@ -379,9 +362,7 @@ class Client { }; }); } - /** - * @param {{ data?: any; headers: any; statusCode?: number; }} res - */ + setReplayNonce(res) { const replayNonce = (res.headers['replay-nonce'] || '').trim(); if (!replayNonce) { @@ -389,9 +370,10 @@ class Client { } this.replayNonce = replayNonce; } + /** - * @param {import("jose").JWSHeaderParameters} header - * @param {import("jose").JWTPayload | undefined} [payload] + * @param {import("jose/types.js").JWSHeaderParameters} header + * @param {import("jose/types.js").JWTPayload | undefined} [payload] */ async sign(header, payload) { if (this.accountPrivateKey == null) { @@ -400,14 +382,12 @@ 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()); @@ -425,4 +405,5 @@ class Client { }); } } -export default Client; \ No newline at end of file + +export default Client; diff --git a/src/certnode/lib/common.js b/src/certnode/lib/common.js index 185cd9f..8de2111 100644 --- a/src/certnode/lib/common.js +++ b/src/certnode/lib/common.js @@ -1,12 +1,12 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; +import crypto from "crypto"; +import fs from "fs"; + export const ACCOUNT_KEY_ALGORITHM = 'ES256'; export const CERTIFICATE_KEY_ALGORITHM = 'RS256'; -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()] || "" +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 PRIVATE_KEY_CIPHER = 'aes-256-cbc'; export const PRIVATE_KEY_FORMAT = 'pem'; export const PRIVATE_KEY_PERMISSIONS = 0o600; @@ -14,10 +14,11 @@ 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'>} */ @@ -29,21 +30,20 @@ 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,25 +79,24 @@ 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'; @@ -105,5 +104,6 @@ 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 25fa608..9a2c919 100644 --- a/src/certnode/lib/request.js +++ b/src/certnode/lib/request.js @@ -1,29 +1,20 @@ -import http from "node:http"; -import https from "node:https"; -/** - * @template T - * @typedef {{data: T, headers: import('http').IncomingHttpHeaders, statusCode: number}} Response - */ +import https from "https"; /** - * @template T * @param {string | URL} url * @param {import('https').RequestOptions & {data?: string}} [options] - * @return {Promise>} + * @param {() => void} [cb] + * @return {Promise<{data: any, headers: import('http').IncomingHttpHeaders, statusCode: number}>} */ -const request = (url, { data = '', ...options } = {}) => { +const request = (url, {data = '', ...options} = {}, cb) => { return new Promise((resolve, reject) => { try { url = new URL(url); - } - catch (err) { + } catch (err) { return reject(err); } - (url.protocol == 'https:' ? https : http).request(url, options, res => { - const { statusCode, headers } = res; - /** - * @type {any} - */ + https.request(url, options, res => { + const {statusCode, headers} = res; let data = ''; res .on('data', chunk => { @@ -33,13 +24,12 @@ const request = (url, { data = '', ...options } = {}) => { 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); }) @@ -49,6 +39,7 @@ const request = (url, { data = '', ...options } = {}) => { const method = options.method || 'GET'; reject(new Error(`${method} request to "${url}" timed out`)); }, 10e3); + cb && cb(); }); }; -export default request; \ No newline at end of file +export default request;