Skip to content

Commit 497ee8f

Browse files
committed
Add TLS fingerprint endpoint
1 parent c203439 commit 497ee8f

File tree

8 files changed

+137
-12
lines changed

8 files changed

+137
-12
lines changed

package-lock.json

Lines changed: 12 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"cookie": "^1.0.2",
3939
"lodash": "^4.17.23",
4040
"parse-multipart-data": "^1.5.0",
41+
"read-tls-client-hello": "^1.1.0",
4142
"tsx": "^4.19.3"
4243
},
4344
"devDependencies": {

src/endpoints/http-index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,5 @@ export * from './http/encoding/gzip.js';
4444
export * from './http/encoding/deflate.js';
4545
export * from './http/encoding/zstd.js';
4646
export * from './http/encoding/brotli.js';
47-
export * from './http/encoding/identity.js';
47+
export * from './http/encoding/identity.js';
48+
export * from './http/tls-fingerprint.js';
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { TLSSocket } from 'tls';
2+
import { HttpEndpoint, HttpRequest } from '../http-index.js';
3+
4+
function getTlsSocket(req: HttpRequest): TLSSocket | undefined {
5+
// HTTP/1: socket is directly available
6+
if (req.socket instanceof TLSSocket) {
7+
return req.socket;
8+
}
9+
10+
// HTTP/2: socket is on the session
11+
const stream = (req as any).stream;
12+
const session = stream?.session;
13+
const socket = session?.socket;
14+
if (socket instanceof TLSSocket) {
15+
return socket;
16+
}
17+
18+
return undefined;
19+
}
20+
21+
export const tlsFingerprint: HttpEndpoint = {
22+
matchPath: (path) => path === '/tls/fingerprint',
23+
handle: (req, res) => {
24+
const tlsSocket = getTlsSocket(req);
25+
26+
if (!tlsSocket) {
27+
res.writeHead(400, { 'content-type': 'application/json' });
28+
res.end(JSON.stringify({ error: 'Not a TLS connection' }));
29+
return;
30+
}
31+
32+
const tlsClientHello = (tlsSocket as any).tlsClientHello;
33+
34+
if (!tlsClientHello?.ja3 || !tlsClientHello?.ja4) {
35+
res.writeHead(500, { 'content-type': 'application/json' });
36+
res.end(JSON.stringify({ error: 'TLS fingerprint not available' }));
37+
return;
38+
}
39+
40+
res.writeHead(200, { 'content-type': 'application/json' });
41+
res.end(JSON.stringify({
42+
ja3: tlsClientHello.ja3,
43+
ja4: tlsClientHello.ja4
44+
}));
45+
}
46+
};

src/process-connection.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -243,14 +243,6 @@ export class ConnectionProcessor {
243243

244244
if (isTLS(initialData)) {
245245
connection.unshift(initialData);
246-
// For TLS, set up data capturing on the raw connection
247-
connection.receivedData = [];
248-
connection.once('readable', () => {
249-
connection.on('data', (data) => {
250-
connection.receivedData?.push(data);
251-
});
252-
});
253-
connection.pause();
254246
this.tlsHandler(connection);
255247
} else if (isHTTP2(initialData)) {
256248
// For HTTP/2, wrap in a capturing stream because http2 module

src/server.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import * as net from 'net';
22

3+
import {
4+
readTlsClientHello,
5+
calculateJa3FromFingerprintData,
6+
calculateJa4FromHelloData,
7+
TlsHelloData
8+
} from 'read-tls-client-hello';
9+
310
import { createHttp1Handler, createHttp2Handler } from './http-handler.js';
411
import { createTlsHandler, CertMode } from './tls-handler.js';
512
import { ConnectionProcessor } from './process-connection.js';
@@ -14,6 +21,11 @@ declare module 'stream' {
1421
// Pipelining detection: tracks concurrent requests
1522
requestsInBatch?: number;
1623
pipelining?: boolean;
24+
// TLS fingerprint data (set for TLS connections)
25+
tlsClientHello?: TlsHelloData & {
26+
ja3: string;
27+
ja4: string;
28+
};
1729
}
1830
}
1931

@@ -108,7 +120,18 @@ async function generateTlsConfig(options: ServerOptions) {
108120

109121
const createTcpHandler = async (options: ServerOptions = {}) => {
110122
const connProcessor = new ConnectionProcessor(
111-
(conn) => {
123+
async (conn) => {
124+
// Read and store TLS fingerprint before TLS handshake
125+
try {
126+
const helloData = await readTlsClientHello(conn);
127+
conn.tlsClientHello = {
128+
...helloData,
129+
ja3: calculateJa3FromFingerprintData(helloData.fingerprintData),
130+
ja4: calculateJa4FromHelloData(helloData)
131+
};
132+
} catch (e) {
133+
// Non-TLS traffic or malformed client hello - continue without fingerprint
134+
}
112135
conn.pause();
113136
tlsHandler.emit('connection', conn);
114137
},

src/tls-handler.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,14 @@ export async function createTlsHandler(
138138

139139
proactivelyRefreshDomains(tlsConfig.proactiveCertDomains ?? [], tlsConfig.generateCertificate);
140140

141+
// Copy TLS fingerprint from underlying socket to TLS socket
142+
server.prependListener('secureConnection', (tlsSocket) => {
143+
const parent = (tlsSocket as any)._parent;
144+
if (parent?.tlsClientHello) {
145+
(tlsSocket as any).tlsClientHello = parent.tlsClientHello;
146+
}
147+
});
148+
141149
server.on('secureConnection', (socket) => {
142150
connProcessor.processConnection(socket);
143151
});

test/tls-fingerprint.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as net from 'net';
2+
import * as https from 'https';
3+
import * as streamConsumers from 'stream/consumers';
4+
5+
import { expect } from 'chai';
6+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
7+
8+
import { createServer } from '../src/server.js';
9+
10+
describe("TLS fingerprint endpoint", () => {
11+
12+
let server: DestroyableServer;
13+
let serverPort: number;
14+
15+
beforeEach(async () => {
16+
server = makeDestroyable(await createServer());
17+
await new Promise<void>((resolve) => server.listen(resolve));
18+
serverPort = (server.address() as net.AddressInfo).port;
19+
});
20+
21+
afterEach(async () => {
22+
await server.destroy();
23+
});
24+
25+
it("returns ja3 and ja4 fingerprints for TLS connections", async () => {
26+
const response = await new Promise<{ ja3: string; ja4: string }>((resolve, reject) => {
27+
https.get(`https://localhost:${serverPort}/tls/fingerprint`, {
28+
rejectUnauthorized: false
29+
}, async (res) => {
30+
try {
31+
const body = await streamConsumers.text(res);
32+
resolve(JSON.parse(body));
33+
} catch (e) {
34+
reject(e);
35+
}
36+
}).on('error', reject);
37+
});
38+
39+
// Expected fingerprints for Node 24's TLS client
40+
expect(response.ja3).to.equal('944d1e1858cd278718f8a46b65d3212f');
41+
expect(response.ja4).to.equal('t13d521100_b262b3658495_8e6e362c5eac');
42+
});
43+
44+
});

0 commit comments

Comments
 (0)