From 505d611d2b3e23f6b917bbfae0af082c7b03e8a7 Mon Sep 17 00:00:00 2001 From: Gldywn <14254051+Gldywn@users.noreply.github.com> Date: Thu, 14 Aug 2025 11:23:23 +0200 Subject: [PATCH 1/2] fix: correct casing of `useNodeDefaultCaBundle` in documentation and examples - Updated references to `useNodeDefaultCABundle` to `useNodeDefaultCaBundle` for consistency across README.md, examples, and source files. - Ensured accurate documentation of the function to prevent confusion regarding its usage. --- README.md | 8 ++++---- examples/custom-options.ts | 2 +- src/index.ts | 2 +- src/options.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8ad18c0..24308a8 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ The options below are used to configure the security policies of the `HardenedHt | **Property** | **Type** | **Required / Variants** | **Helper(s)** | | ------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | -| `ca` | `string \| Buffer \| Array` | Required. Custom trust store that replaces Node.js defaults. Accepts PEM string, `Buffer`, or an array of either. | `embeddedCfsslCaBundle`, `useNodeDefaultCABundle()` | +| `ca` | `string \| Buffer \| Array` | Required. Custom trust store that replaces Node.js defaults. Accepts PEM string, `Buffer`, or an array of either. | `embeddedCfsslCaBundle`, `useNodeDefaultCaBundle()` | | `ctPolicy` | `CertificateTransparencyPolicy` | Optional. Enables CT when present. Fields: `logList: UnifiedCTLogList`, `minEmbeddedScts?: number`, `minDistinctOperators?: number`. | `basicCtPolicy()`, `embeddedUnifiedCtLogList` | | `ocspPolicy` | `OCSPPolicy` | Optional. Enables OCSP when present. Fields: `mode: 'mixed' \| 'stapling' \| 'direct'`, `failHard: boolean`. | `basicStaplingOcspPolicy()`, `basicDirectOcspPolicy()` | | `crlSetPolicy` | `CRLSetPolicy` | Optional. Enables CRLSet when present. Fields: `crlSet?: CRLSet`, `verifySignature?: boolean`, `updateStrategy?: 'always' \| 'on-expiry'`. | `basicCrlSetPolicy()` | @@ -199,7 +199,7 @@ Import convenience presets and building blocks as needed, to help you construct ```typescript import { defaultAgentOptions, - useNodeDefaultCABundle, + useNodeDefaultCaBundle, embeddedCfsslCaBundle, embeddedUnifiedCtLogList, basicCtPolicy, @@ -216,7 +216,7 @@ The default helpers such as `embeddedCfsslCaBundle` and `embeddedUnifiedCtLogLis - These embedded resources are refreshed via an automated weekly GitHub Action that fetches the latest upstream data and opens a pull request to update the repository. Updates are executed on GitHub’s infrastructure and are fully auditable in pull request diffs and timestamps. - If you choose to rely on embedded resources, you are responsible for updating the library in your project to receive the refreshed data at your desired cadence. -- Alternatively, you can opt into Node's default CA bundle with `useNodeDefaultCABundle()` if that trust model better suits your environment. +- Alternatively, you can opt into Node's default CA bundle with `useNodeDefaultCaBundle()` if that trust model better suits your environment. - You can also choose not to rely on embedded resources at all: provide your own CA bundle, your own unified CT log list, and configure every other property yourself. Everything is fully customizable. ### `HardenedHttpsAgent` customization (quick recipes) @@ -230,7 +230,7 @@ new HardenedHttpsAgent({ ...defaultAgentOptions(), ca: myPemStringOrBuffer }); Use Node default CA bundle: ```typescript -new HardenedHttpsAgent({ ...defaultAgentOptions(), ca: useNodeDefaultCABundle() }); +new HardenedHttpsAgent({ ...defaultAgentOptions(), ca: useNodeDefaultCaBundle() }); ``` Tune standard `https.Agent` behavior: diff --git a/examples/custom-options.ts b/examples/custom-options.ts index d576397..1d42ad2 100644 --- a/examples/custom-options.ts +++ b/examples/custom-options.ts @@ -17,7 +17,7 @@ async function main() { const agent = new HardenedHttpsAgent( { ...httpsAgentOptions, - ca: embeddedCfsslCaBundle, // or *your custom ca bundle* | useNodeDefaultCABundle() + ca: embeddedCfsslCaBundle, // or *your custom ca bundle* | useNodeDefaultCaBundle() ctPolicy: { logList: embeddedUnifiedCtLogList, // or *your custom log list* minEmbeddedScts: 2, diff --git a/src/index.ts b/src/index.ts index 45527a4..a4d1aaf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ export type { } from './interfaces'; export { - useNodeDefaultCABundle, + useNodeDefaultCaBundle, embeddedCfsslCaBundle, embeddedUnifiedCtLogList, basicCtPolicy, diff --git a/src/options.ts b/src/options.ts index 5308617..a77a27b 100644 --- a/src/options.ts +++ b/src/options.ts @@ -30,7 +30,7 @@ export const NODE_DEFAULT_CA_SENTINEL = '__USE_NODE_DEFAULT_CA_BUNDLE__'; * bundled Mozilla store, not necessarily the OS store, unless `--use-system-ca` or a * platform-specific build enables it. */ -export function useNodeDefaultCABundle(): string { +export function useNodeDefaultCaBundle(): string { return NODE_DEFAULT_CA_SENTINEL; } From 0ef2d14b517dd880856b51f1945c5363477c6e25 Mon Sep 17 00:00:00 2001 From: Gldywn <14254051+Gldywn@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:04:44 +0200 Subject: [PATCH 2/2] refactor(validation-kit-events): enhance event handling and simplify socket attachment - Introduced a typed event emitter for validation events in HardenedHttpsValidationKit, allowing for better event management. - Updated the attachToSocket method to remove the callback, relying on events for success and error handling. - Improved the createConnection method in HardenedHttpsAgent to handle validation success through events. - Adjusted tests to reflect changes in event handling and socket attachment logic. --- src/agent.ts | 14 ++++++--- src/index.ts | 2 +- src/validation-kit.ts | 46 +++++++++++++++++++++------- test/agent.test.ts | 4 +-- test/ct-validator.test.ts | 5 +-- test/e2e/acceptance.test.ts | 3 +- test/e2e/agent.test.ts | 2 +- test/e2e/failure.test.ts | 3 +- test/e2e/validation-kit.test.ts | 2 +- test/ocsp-direct-validator.test.ts | 4 +-- test/ocsp-mixed-validator.test.ts | 3 +- test/ocsp-stapling-validator.test.ts | 3 +- test/utils/index.ts | 3 +- test/validation-kit.test.ts | 18 ++++++----- 14 files changed, 68 insertions(+), 44 deletions(-) diff --git a/src/agent.ts b/src/agent.ts index 556cd80..9781f51 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -35,19 +35,25 @@ export class HardenedHttpsAgent extends Agent { ): Duplex { this.#logger?.log('Initiating new TLS connection...'); + // Handle validation success + this.#kit.once('validation:success', (tlsSocket) => { + callback(null, tlsSocket); + }); + // Allow validators to modify the connection options const finalOptions = this.#kit.applyBeforeConnect(options); + // Create the socket const socket = tls.connect(finalOptions); - // Attach the validation kit to the socket - // The socket will be passed back to the callback from the validation kit - this.#kit.attachToSocket(socket, callback); - + // Handle socket errors socket.on('error', (err: Error) => { this.#logger?.error('A socket error occurred during connection setup.', err); callback(err, undefined as any); }); + // Attach the validation kit to the socket + this.#kit.attachToSocket(socket); + return undefined as any; } } diff --git a/src/index.ts b/src/index.ts index a4d1aaf..5a1f9b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,4 +19,4 @@ export { defaultAgentOptions, } from './options'; -export { HardenedHttpsValidationKit } from './validation-kit'; +export { HardenedHttpsValidationKit, type ValidationKitEvents } from './validation-kit'; diff --git a/src/validation-kit.ts b/src/validation-kit.ts index 000c476..e49750f 100644 --- a/src/validation-kit.ts +++ b/src/validation-kit.ts @@ -1,6 +1,6 @@ -import tls from 'node:tls'; -import https from 'node:https'; import http from 'node:http'; +import https from 'node:https'; +import tls from 'node:tls'; import { Logger, LogSink } from './logger'; import type { HardenedHttpsValidationKitOptions } from './interfaces'; import { BaseValidator } from './validators/base'; @@ -11,15 +11,36 @@ import { OCSPMixedValidator, CRLSetValidator, } from './validators'; -import { Duplex } from 'node:stream'; +import { EventEmitter } from 'node:events'; + +export type ValidationKitEvents = { + 'validation:success': (tlsSocket: tls.TLSSocket) => void; + 'validation:error': (error: Error) => void; +}; + +class TypedEventEmitter void>> extends EventEmitter { + public override on(eventName: K, listener: Events[K]): this { + return super.on(eventName, listener as (...args: any[]) => void); + } + public override once(eventName: K, listener: Events[K]): this { + return super.once(eventName, listener as (...args: any[]) => void); + } + public override off(eventName: K, listener: Events[K]): this { + return super.off(eventName, listener as (...args: any[]) => void); + } + public override emit(eventName: K, ...args: Parameters): boolean { + return super.emit(eventName, ...args); + } +} -export class HardenedHttpsValidationKit { +export class HardenedHttpsValidationKit extends TypedEventEmitter { private readonly options: HardenedHttpsValidationKitOptions; private readonly logger: Logger | undefined; private readonly validators: BaseValidator[]; private readonly validatedSockets: WeakSet = new WeakSet(); constructor(options: HardenedHttpsValidationKitOptions, sink?: LogSink) { + super(); this.options = options; if (options.enableLogging) this.logger = new Logger(this.constructor.name, sink); @@ -47,12 +68,15 @@ export class HardenedHttpsValidationKit { return finalOptions as T; } - private runValidation(tlsSocket: tls.TLSSocket, callback?: (err: Error | null, stream: Duplex) => void): void { + private runValidation(tlsSocket: tls.TLSSocket): void { if (this.validatedSockets.has(tlsSocket)) return; this.validatedSockets.add(tlsSocket); const active = this.getActiveValidators(); - if (active.length === 0) return callback?.(null, tlsSocket); + if (active.length === 0) { + this.emit('validation:success', tlsSocket); + return; + } let shouldResume = false; try { @@ -77,18 +101,18 @@ export class HardenedHttpsValidationKit { this.logger?.warn('Failed to resume socket', err); } } - callback?.(null, tlsSocket); + this.emit('validation:success', tlsSocket); }) .catch((err: Error) => { this.logger?.error('An error occurred during validation', err); - callback?.(err, undefined as any); - // TODO: tlsSocket.destroy(err); ? + tlsSocket.destroy(err); // Destroy the socket to prevent further use (and force error propagation to eventual attached agent) + this.emit('validation:error', err); }); } - public attachToSocket(tlsSocket: tls.TLSSocket, callback?: (err: Error | null, stream: Duplex) => void): void { + public attachToSocket(tlsSocket: tls.TLSSocket): void { if (this.validatedSockets.has(tlsSocket)) return; - this.runValidation(tlsSocket, callback); + this.runValidation(tlsSocket); } /* istanbul ignore next */ diff --git a/test/agent.test.ts b/test/agent.test.ts index dbed6f3..25c3227 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -1,4 +1,3 @@ -import { Duplex } from 'node:stream'; import tls, { TLSSocket } from 'node:tls'; import { HardenedHttpsAgent } from '../src/agent'; import { HardenedHttpsAgentOptions } from '../src/interfaces'; @@ -33,6 +32,7 @@ describe('HardenedHttpsAgent', () => { const kit = { applyBeforeConnect: jest.fn((opts) => opts), attachToSocket: jest.fn(), + once: jest.fn(), }; // Assign the mock instance to our variable so we can assert calls on it mockValidationKit = kit as unknown as jest.Mocked; @@ -73,7 +73,7 @@ describe('HardenedHttpsAgent', () => { const agent = new HardenedHttpsAgent(baseOptions); agent.createConnection({}, jest.fn()); - expect(mockValidationKit.attachToSocket).toHaveBeenCalledWith(mockSocket, expect.any(Function)); + expect(mockValidationKit.attachToSocket).toHaveBeenCalledWith(mockSocket); }); test('should use the connection options returned by applyBeforeConnect', () => { diff --git a/test/ct-validator.test.ts b/test/ct-validator.test.ts index 07b46c5..17ec017 100644 --- a/test/ct-validator.test.ts +++ b/test/ct-validator.test.ts @@ -1,11 +1,8 @@ import type { CertificateTransparencyPolicy } from '../src/interfaces'; import * as tls from 'node:tls'; -import { loadTestCertsChain, getTestHardenedHttpsAgent, TEST_CT_POLICY } from './utils'; -import { TEST_CERT_HOSTS } from '../scripts/constants'; +import { loadTestCertsChain, getTestHardenedHttpsAgent, TEST_CT_POLICY, createMockSocket, createMockPeerCertificate } from './utils'; import { SCT_EXTENSION_OID_V1 } from '@gldywn/sct.js'; -import { createMockSocket, createMockPeerCertificate, delay } from './utils'; import { CTValidator } from '../src/validators'; -import { WrappedError } from '../src/validators/base'; jest.mock('node:tls'); diff --git a/test/e2e/acceptance.test.ts b/test/e2e/acceptance.test.ts index 1b7aea3..ac84caf 100644 --- a/test/e2e/acceptance.test.ts +++ b/test/e2e/acceptance.test.ts @@ -1,5 +1,5 @@ import axios, { type AxiosRequestConfig } from 'axios'; -import { delay } from '../utils'; +import { delay, spoofedAxios } from '../utils'; import { basicCtPolicy, basicDirectOcspPolicy, @@ -7,7 +7,6 @@ import { HardenedHttpsAgent, type HardenedHttpsAgentOptions, } from '../../src'; -import { spoofedAxios } from '../utils/spoofedAxios'; import { basicMixedOcspPolicy, defaultAgentOptions, embeddedCfsslCaBundle } from '../../src/options'; // Note: This test file is not completely stable because it relies on network. diff --git a/test/e2e/agent.test.ts b/test/e2e/agent.test.ts index 8258402..37d276f 100644 --- a/test/e2e/agent.test.ts +++ b/test/e2e/agent.test.ts @@ -1,7 +1,7 @@ import https from 'node:https'; import axios from 'axios'; import { HardenedHttpsAgent } from '../../src/agent'; -import { getCa, startTlsServer } from '../utils/server'; +import { getCa, startTlsServer } from '../utils'; describe('End-to-end HardenedHttpsAgent integration', () => { let server: ReturnType; diff --git a/test/e2e/failure.test.ts b/test/e2e/failure.test.ts index d91b6db..c620989 100644 --- a/test/e2e/failure.test.ts +++ b/test/e2e/failure.test.ts @@ -1,6 +1,5 @@ import { type AxiosRequestConfig } from 'axios'; -import { delay } from '../utils'; -import { spoofedAxios } from '../utils/spoofedAxios'; +import { delay, spoofedAxios } from '../utils'; import { HardenedHttpsAgent, type HardenedHttpsAgentOptions } from '../../src'; import { basicCtPolicy, diff --git a/test/e2e/validation-kit.test.ts b/test/e2e/validation-kit.test.ts index 0d84b92..ba307d5 100644 --- a/test/e2e/validation-kit.test.ts +++ b/test/e2e/validation-kit.test.ts @@ -1,7 +1,7 @@ import https from 'node:https'; import tls from 'node:tls'; import { HardenedHttpsValidationKit } from '../../src/validation-kit'; -import { getCa, startTlsServer } from '../utils/server'; +import { getCa, startTlsServer } from '../utils'; import { basicCtPolicy } from '../../src/options'; jest.mock('../../src/validators/ct', () => ({ diff --git a/test/ocsp-direct-validator.test.ts b/test/ocsp-direct-validator.test.ts index 0ac088e..01024c7 100644 --- a/test/ocsp-direct-validator.test.ts +++ b/test/ocsp-direct-validator.test.ts @@ -1,10 +1,8 @@ import type { OCSPPolicy } from '../src/interfaces'; import * as tls from 'node:tls'; -import { loadTestCertsChain, getTestHardenedHttpsAgent } from './utils'; -import { createMockSocket, createMockPeerCertificate } from './utils/createMock'; +import { loadTestCertsChain, getTestHardenedHttpsAgent, createMockSocket, createMockPeerCertificate } from './utils'; import * as easyOcsp from 'easy-ocsp'; import { OCSPDirectValidator } from '../src/validators'; -import { WrappedError } from '../src/validators/base'; jest.mock('node:tls'); jest.mock('easy-ocsp'); diff --git a/test/ocsp-mixed-validator.test.ts b/test/ocsp-mixed-validator.test.ts index 9ce30b0..5f73561 100644 --- a/test/ocsp-mixed-validator.test.ts +++ b/test/ocsp-mixed-validator.test.ts @@ -1,7 +1,6 @@ import type { OCSPPolicy } from '../src/interfaces'; import * as tls from 'node:tls'; -import { loadTestCertsChain, getTestHardenedHttpsAgent } from './utils'; -import { createMockSocket, createMockPeerCertificate } from './utils/createMock'; +import { loadTestCertsChain, getTestHardenedHttpsAgent, createMockSocket, createMockPeerCertificate } from './utils'; import * as easyOcsp from 'easy-ocsp'; jest.mock('node:tls'); diff --git a/test/ocsp-stapling-validator.test.ts b/test/ocsp-stapling-validator.test.ts index bc0400f..5b43719 100644 --- a/test/ocsp-stapling-validator.test.ts +++ b/test/ocsp-stapling-validator.test.ts @@ -1,7 +1,6 @@ import type { OCSPPolicy } from '../src/interfaces'; import * as tls from 'node:tls'; -import { loadTestCertsChain, getTestHardenedHttpsAgent } from './utils'; -import { createMockSocket, createMockPeerCertificate } from './utils/createMock'; +import { loadTestCertsChain, getTestHardenedHttpsAgent, createMockSocket, createMockPeerCertificate } from './utils'; import * as easyOcsp from 'easy-ocsp'; import { OCSPStaplingValidator } from '../src/validators'; diff --git a/test/utils/index.ts b/test/utils/index.ts index 369948c..26f77fe 100644 --- a/test/utils/index.ts +++ b/test/utils/index.ts @@ -4,9 +4,10 @@ import { Certificate } from 'pkijs'; import { fromBER } from 'asn1js'; import { getTestDataDir } from '../../scripts/utils'; import { HardenedHttpsAgent, CertificateTransparencyPolicy, OCSPPolicy, CRLSetPolicy } from '../../src'; -import { CRLSet } from '@gldywn/crlset.js'; export { createMockSocket, createMockPeerCertificate } from './createMock'; +export { startTlsServer, getCa } from './server'; +export { spoofedAxios } from './spoofedAxios'; const TEST_DATA_DIR = getTestDataDir(); diff --git a/test/validation-kit.test.ts b/test/validation-kit.test.ts index ad1f3a0..029007f 100644 --- a/test/validation-kit.test.ts +++ b/test/validation-kit.test.ts @@ -120,30 +120,32 @@ describe('HardenedHttpsValidationKit', () => { mockOcspStaplingValidator.validate.mockResolvedValue(undefined); const kit = new HardenedHttpsValidationKit(baseOptions); - kit.attachToSocket(mockSocket, (err) => { - expect(err).toBeNull(); + kit.once('validation:success', (tlsSocket) => { + expect(tlsSocket).toBe(mockSocket); + expect(mockSocket.pause).toHaveBeenCalled(); expect(mockSocket.resume).toHaveBeenCalled(); - done(); }); - - expect(mockSocket.pause).toHaveBeenCalled(); - expect(mockSocket.resume).not.toHaveBeenCalled(); + kit.attachToSocket(mockSocket); }); test('should destroy the socket if any active validator fails', (done) => { + // Mock the socket.destroy method to avoid unhandled error propagation + mockSocket.destroy = jest.fn(); + const validationError = new Error('Validation failed'); mockCtValidator.shouldRun.mockReturnValue(true); mockCtValidator.validate.mockRejectedValue(validationError); const kit = new HardenedHttpsValidationKit(baseOptions); - kit.attachToSocket(mockSocket, (err) => { + kit.once('validation:error', (err) => { expect(err).toBe(validationError); - // TODO: Check this -> expect(mockSocket.destroy).toHaveBeenCalledWith(validationError); + expect(mockSocket.destroy).toHaveBeenCalledWith(validationError); expect(mockSocket.resume).not.toHaveBeenCalled(); done(); }); + kit.attachToSocket(mockSocket); }); test('should pass modified options from one validator to the next during onBeforeConnect', () => {