Skip to content

Commit ea669ee

Browse files
committed
Add TLS version endpoints, restructuring the TLS handler approach
We now manually build a TLSSocket on each socket, instead of using a server. Mostly simpler, some small workarounds required.
1 parent 7c8b231 commit ea669ee

File tree

7 files changed

+525
-81
lines changed

7 files changed

+525
-81
lines changed

src/endpoints/tls-index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export * from './tls/alpn-specifiers.js';
1212
export * from './tls/cert-modes.js';
1313
export * from './tls/example.js';
1414
export * from './tls/no-tls.js';
15+
export * from './tls/tls-versions.js';

src/endpoints/tls/tls-versions.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import * as tls from 'tls';
2+
import * as crypto from 'crypto';
3+
import { TlsEndpoint } from '../tls-index.js';
4+
5+
const {
6+
SSL_OP_NO_TLSv1,
7+
SSL_OP_NO_TLSv1_1,
8+
SSL_OP_NO_TLSv1_2,
9+
SSL_OP_NO_TLSv1_3
10+
} = crypto.constants;
11+
12+
const ALL_VERSIONS_DISABLED = SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1 | SSL_OP_NO_TLSv1_2 | SSL_OP_NO_TLSv1_3;
13+
14+
const VERSION_FLAGS: Record<tls.SecureVersion, number> = {
15+
'TLSv1': SSL_OP_NO_TLSv1,
16+
'TLSv1.1': SSL_OP_NO_TLSv1_1,
17+
'TLSv1.2': SSL_OP_NO_TLSv1_2,
18+
'TLSv1.3': SSL_OP_NO_TLSv1_3,
19+
};
20+
21+
const VERSION_ORDER: tls.SecureVersion[] = ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'];
22+
23+
function enableTlsVersion(opts: tls.SecureContextOptions, version: tls.SecureVersion) {
24+
// Start with all versions disabled if not set
25+
if (opts.secureOptions === undefined) {
26+
opts.secureOptions = ALL_VERSIONS_DISABLED;
27+
}
28+
29+
// Remove the disable flag for this version (enable it)
30+
opts.secureOptions &= ~VERSION_FLAGS[version];
31+
32+
// Set minVersion to the lowest enabled version (Node.js defaults to TLSv1.2)
33+
const versionIndex = VERSION_ORDER.indexOf(version);
34+
const currentMinIndex = opts.minVersion
35+
? VERSION_ORDER.indexOf(opts.minVersion)
36+
: VERSION_ORDER.length;
37+
38+
if (versionIndex < currentMinIndex) {
39+
opts.minVersion = version;
40+
}
41+
42+
// Legacy TLS versions require lowered cipher security level
43+
if (versionIndex <= 1 && !opts.ciphers?.includes('@SECLEVEL=0')) {
44+
opts.ciphers = `${opts.ciphers || 'DEFAULT'}@SECLEVEL=0`;
45+
}
46+
}
47+
48+
export const tlsV1: TlsEndpoint = {
49+
sniPart: 'tls-v1-0',
50+
configureTlsOptions(tlsOptions) {
51+
enableTlsVersion(tlsOptions, 'TLSv1');
52+
return tlsOptions;
53+
},
54+
};
55+
56+
export const tlsV1_1: TlsEndpoint = {
57+
sniPart: 'tls-v1-1',
58+
configureTlsOptions(tlsOptions) {
59+
enableTlsVersion(tlsOptions, 'TLSv1.1');
60+
return tlsOptions;
61+
},
62+
};
63+
64+
export const tlsV1_2: TlsEndpoint = {
65+
sniPart: 'tls-v1-2',
66+
configureTlsOptions(tlsOptions) {
67+
enableTlsVersion(tlsOptions, 'TLSv1.2');
68+
return tlsOptions;
69+
},
70+
};
71+
72+
export const tlsV1_3: TlsEndpoint = {
73+
sniPart: 'tls-v1-3',
74+
configureTlsOptions(tlsOptions) {
75+
enableTlsVersion(tlsOptions, 'TLSv1.3');
76+
return tlsOptions;
77+
},
78+
};

src/http-handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { HttpRequest, HttpResponse } from './endpoints/http-index.js';
88

99
const MAX_CHAIN_DEPTH = 10;
1010

11-
function resolveEndpointChain(initialPath: string, hostnamePrefix: string | undefined) {
11+
function resolveEndpointChain(initialPath: string, hostnamePrefix?: string) {
1212
const entries: Array<{ endpoint: typeof httpEndpoints[number]; path: string }> = [];
1313
let needsRawData = false;
1414
let path: string | undefined = initialPath;

src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ const createTcpHandler = async (options: ServerOptions = {}) => {
140140
// Non-TLS traffic or malformed client hello - continue without fingerprint
141141
}
142142
conn.pause();
143-
tlsHandler.emit('connection', conn);
143+
tlsHandler.handleConnection(conn);
144144
},
145145
(conn) => httpHandler.emit('connection', conn),
146146
(conn) => http2Handler.emit('connection', conn)

src/tls-certificates/local-ca.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const crypto = globalThis.crypto;
1010

1111
// This is all approximately based on Mockttp's src/util/certificates.ts CA implementation
1212

13-
export interface CAOptions {
13+
interface CAOptions {
1414
key: string;
1515
cert: string;
1616

src/tls-handler.ts

Lines changed: 114 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import * as tls from 'tls';
22
import * as crypto from 'node:crypto';
3+
import * as stream from 'stream';
4+
import { EventEmitter } from 'events';
35

46
import { ConnectionProcessor } from './process-connection.js';
57
import { LocalCA } from './tls-certificates/local-ca.js';
68
import { CertOptions, calculateCertCacheKey } from './tls-certificates/cert-definitions.js';
79
import { SecureContextCache } from './tls-certificates/secure-context-cache.js';
810
import { tlsEndpoints } from './endpoints/endpoint-index.js';
11+
import { ErrorLike } from '@httptoolkit/util';
912

1013
const secureContextCache = new SecureContextCache();
1114

@@ -40,7 +43,7 @@ interface TlsHandlerConfig {
4043
cert: string;
4144
ca: string;
4245
generateCertificate: CertGenerator;
43-
localCA?: LocalCA;
46+
localCA: LocalCA;
4447
}
4548

4649
const DEFAULT_ALPN_PROTOCOLS = ['http/1.1', 'h2'];
@@ -54,7 +57,7 @@ const getSNIPrefixParts = (servername: string, rootDomain: string) => {
5457
return serverNamePrefix.split('.');
5558
};
5659

57-
const MAX_SNI_PARTS = 3;
60+
const MAX_SNI_PARTS = 4;
5861

5962
const PROACTIVE_DOMAIN_REFRESH_INTERVAL = 1000 * 60 * 60 * 24; // Daily cert check for proactive domains
6063

@@ -92,82 +95,26 @@ function getEndpointConfig(serverNameParts: string[]) {
9295
return { certOptions, tlsOptions, alpnPreferences };
9396
}
9497

95-
export async function createTlsHandler(
96-
tlsConfig: TlsHandlerConfig,
97-
connProcessor: ConnectionProcessor
98-
) {
99-
const server = tls.createServer({
100-
key: tlsConfig.key,
101-
cert: tlsConfig.cert,
102-
ca: [tlsConfig.ca],
103-
104-
ALPNCallback: ({ servername, protocols: clientProtocols }) => {
105-
const { alpnPreferences } = getEndpointConfig(getSNIPrefixParts(servername, tlsConfig.rootDomain));
106-
const protocols = alpnPreferences.length > 0 ? alpnPreferences : DEFAULT_ALPN_PROTOCOLS;
107-
// Enforce our own preference order (client can specify via SNI e.g. http2.http1.*)
108-
return protocols.find(p => clientProtocols.includes(p));
109-
},
110-
SNICallback: async (domain: string, cb: Function) => {
111-
try {
112-
const serverNameParts = getSNIPrefixParts(domain, tlsConfig.rootDomain);
113-
114-
if (serverNameParts.length > MAX_SNI_PARTS) {
115-
return cb(new Error(`Too many SNI parts (${serverNameParts.length})`), null);
116-
}
117-
118-
const uniqueParts = new Set(serverNameParts);
119-
if (uniqueParts.size !== serverNameParts.length) {
120-
return cb(new Error(`Duplicate SNI parts in '${domain}'`), null);
121-
}
122-
123-
const { certOptions, tlsOptions } = getEndpointConfig(serverNameParts);
124-
125-
const certDomain = certOptions.overridePrefix
126-
? `${certOptions.overridePrefix}.${tlsConfig.rootDomain}`
127-
: domain;
128-
129-
const cacheKey = calculateContextCacheKey(certDomain, certOptions, tlsOptions);
130-
131-
const secureContext = await secureContextCache.getOrCreate(cacheKey, async () => {
132-
const cert = await tlsConfig.generateCertificate(certDomain, certOptions);
133-
return {
134-
context: tls.createSecureContext({
135-
key: cert.key,
136-
cert: cert.cert,
137-
ca: cert.ca,
138-
...tlsOptions
139-
}),
140-
expiry: getCertExpiry(cert.cert)
141-
};
142-
});
143-
144-
cb(null, secureContext);
145-
} catch (e) {
146-
console.error('TLS setup error', e);
147-
cb(e);
148-
}
149-
}
150-
});
151-
152-
proactivelyRefreshDomains(tlsConfig.rootDomain, tlsConfig.proactiveCertDomains ?? [], tlsConfig.generateCertificate);
153-
154-
// Copy TLS fingerprint from underlying socket to TLS socket
155-
server.prependListener('secureConnection', (tlsSocket) => {
156-
const parent = (tlsSocket as any)._parent;
157-
if (parent?.tlsClientHello) {
158-
(tlsSocket as any).tlsClientHello = parent.tlsClientHello;
159-
}
160-
});
98+
class TlsConnectionHandler {
16199

162-
// Handle OCSP stapling requests
163-
if (tlsConfig.localCA) {
164-
server.on('OCSPRequest', async (cert, issuer, callback) => {
100+
// To keep Node happy, we need a TLS server attached to our sockets in some cases
101+
// to enable some features (like OCSP). This'll do:
102+
private ocspServer = new EventEmitter();
103+
104+
constructor(
105+
private tlsConfig: TlsHandlerConfig,
106+
private connProcessor: ConnectionProcessor
107+
) {
108+
this.ocspServer.on('OCSPRequest', async (
109+
certificate: Buffer,
110+
_issuer: Buffer,
111+
callback: (err: Error | null, response: Buffer) => void
112+
) => {
165113
try {
166-
const ocspResponse = await tlsConfig.localCA!.getOcspResponse(cert);
114+
const ocspResponse = await this.tlsConfig.localCA!.getOcspResponse(certificate);
167115
if (ocspResponse) {
168116
callback(null, ocspResponse);
169117
} else {
170-
// No OCSP response available - don't staple anything
171118
callback(null, Buffer.alloc(0));
172119
}
173120
} catch (e) {
@@ -177,9 +124,98 @@ export async function createTlsHandler(
177124
});
178125
}
179126

180-
server.on('secureConnection', (socket) => {
181-
connProcessor.processConnection(socket);
182-
});
127+
async handleConnection(rawSocket: stream.Duplex) {
128+
try {
129+
const serverName = rawSocket.tlsClientHello?.serverName;
130+
const domain = serverName || this.tlsConfig.rootDomain;
131+
132+
const serverNameParts = getSNIPrefixParts(domain, this.tlsConfig.rootDomain);
133+
134+
if (serverNameParts.length > MAX_SNI_PARTS) {
135+
console.error(`Too many SNI parts (${serverNameParts.length})`);
136+
rawSocket.destroy();
137+
return;
138+
}
139+
140+
const uniqueParts = new Set(serverNameParts);
141+
if (uniqueParts.size !== serverNameParts.length) {
142+
console.error(`Duplicate SNI parts in '${domain}'`);
143+
rawSocket.destroy();
144+
return;
145+
}
146+
147+
const { certOptions, tlsOptions, alpnPreferences } = getEndpointConfig(serverNameParts);
148+
149+
const certDomain = certOptions.overridePrefix
150+
? `${certOptions.overridePrefix}.${this.tlsConfig.rootDomain}`
151+
: domain;
152+
153+
const cacheKey = calculateContextCacheKey(certDomain, certOptions, tlsOptions);
154+
155+
const secureContext = await secureContextCache.getOrCreate(cacheKey, async () => {
156+
const cert = await this.tlsConfig.generateCertificate(certDomain, certOptions);
157+
return {
158+
context: tls.createSecureContext({
159+
key: cert.key,
160+
cert: cert.cert,
161+
ca: cert.ca,
162+
...tlsOptions
163+
}),
164+
expiry: getCertExpiry(cert.cert)
165+
};
166+
});
167+
168+
const alpnProtocols = alpnPreferences.length > 0
169+
? alpnPreferences
170+
: DEFAULT_ALPN_PROTOCOLS;
171+
172+
// Check if client requested OCSP stapling (extension 5 = status_request)
173+
const clientExtensions = rawSocket.tlsClientHello?.fingerprintData?.[2];
174+
const clientRequestedOCSP = clientExtensions?.includes(5) ?? false;
175+
176+
const tlsSocket = new tls.TLSSocket(rawSocket, {
177+
isServer: true,
178+
secureContext,
179+
ALPNProtocols: alpnProtocols,
180+
// Only set up OCSP machinery if client requested it
181+
...(clientRequestedOCSP ? {
182+
server: this.ocspServer as tls.Server,
183+
// Stub SNICallback to works around a Node limitation where non-server TLS
184+
// sockets don't call OCSPRequest in most cases.
185+
SNICallback: (
186+
_servername: string,
187+
callback: (err: Error | null, ctx?: tls.SecureContext) => void
188+
) => callback(null, secureContext)
189+
} : {})
190+
});
191+
192+
// Transfer tlsClientHello metadata
193+
if (rawSocket.tlsClientHello) {
194+
tlsSocket.tlsClientHello = rawSocket.tlsClientHello;
195+
}
196+
197+
tlsSocket.on('secure', () => {
198+
this.connProcessor.processConnection(tlsSocket);
199+
});
200+
201+
tlsSocket.on('error', (err: ErrorLike) => {
202+
// Expected errors during handshake (version mismatch, etc.)
203+
if (err.code !== 'ECONNRESET') {
204+
console.error('TLS socket error:', err.message);
205+
}
206+
});
207+
} catch (e) {
208+
console.error('TLS setup error', e);
209+
rawSocket.destroy();
210+
}
211+
}
212+
}
183213

184-
return server;
185-
}
214+
export async function createTlsHandler(
215+
tlsConfig: TlsHandlerConfig,
216+
connProcessor: ConnectionProcessor
217+
) {
218+
const handler = new TlsConnectionHandler(tlsConfig, connProcessor);
219+
proactivelyRefreshDomains(tlsConfig.rootDomain, tlsConfig.proactiveCertDomains ?? [], tlsConfig.generateCertificate);
220+
return handler;
221+
}

0 commit comments

Comments
 (0)