Skip to content

Commit 77dbe17

Browse files
committed
Add /tls/client-hello endpoint
1 parent 6525df7 commit 77dbe17

6 files changed

Lines changed: 298 additions & 30 deletions

File tree

src/endpoints/http-index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,5 @@ export * from './http/encoding/deflate.js';
4848
export * from './http/encoding/zstd.js';
4949
export * from './http/encoding/brotli.js';
5050
export * from './http/encoding/identity.js';
51-
export * from './http/tls-fingerprint.js';
51+
export * from './http/tls-fingerprint.js';
52+
export * from './http/tls-client-hello.js';
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {
2+
CIPHER_SUITES,
3+
EXTENSIONS,
4+
SUPPORTED_GROUPS,
5+
SIGNATURE_ALGORITHMS,
6+
EC_POINT_FORMATS,
7+
TLS_VERSIONS,
8+
COMPRESSION_METHODS,
9+
PSK_KEY_EXCHANGE_MODES,
10+
CERTIFICATE_COMPRESSION_ALGORITHMS,
11+
CERTIFICATE_STATUS_TYPES
12+
} from 'read-tls-client-hello';
13+
14+
import { HttpEndpoint } from '../http-index.js';
15+
import { getClientHello } from '../../tls-client-hello.js';
16+
17+
function annotateId(id: number, table: Record<number, string | undefined>) {
18+
return { id, name: table[id] ?? null };
19+
}
20+
21+
function annotateIds(ids: number[], table: Record<number, string | undefined>) {
22+
return ids.map(id => annotateId(id, table));
23+
}
24+
25+
function annotateExtensionData(
26+
extensionId: number,
27+
data: Record<string, unknown> | null
28+
): unknown {
29+
if (data === null || Object.keys(data).length === 0) return data;
30+
31+
switch (extensionId) {
32+
case 5: // status_request
33+
return {
34+
...data,
35+
statusType: annotateId(data.statusType as number, CERTIFICATE_STATUS_TYPES)
36+
};
37+
case 10: // supported_groups
38+
return {
39+
groups: annotateIds(data.groups as number[], SUPPORTED_GROUPS)
40+
};
41+
case 11: // ec_point_formats
42+
return {
43+
formats: annotateIds(data.formats as number[], EC_POINT_FORMATS)
44+
};
45+
case 13: // signature_algorithms
46+
case 50: // signature_algorithms_cert
47+
return {
48+
algorithms: annotateIds(data.algorithms as number[], SIGNATURE_ALGORITHMS)
49+
};
50+
case 17: // status_request_v2
51+
return {
52+
statusTypes: annotateIds(data.statusTypes as number[], CERTIFICATE_STATUS_TYPES)
53+
};
54+
case 27: // compress_certificate
55+
return {
56+
algorithms: annotateIds(
57+
data.algorithms as number[],
58+
CERTIFICATE_COMPRESSION_ALGORITHMS
59+
)
60+
};
61+
case 43: // supported_versions
62+
return {
63+
versions: annotateIds(data.versions as number[], TLS_VERSIONS)
64+
};
65+
case 45: // psk_key_exchange_modes
66+
return {
67+
modes: annotateIds(data.modes as number[], PSK_KEY_EXCHANGE_MODES)
68+
};
69+
case 51: // key_share
70+
return {
71+
entries: (data.entries as Array<{ group: number; keyExchangeLength: number }>)
72+
.map(entry => ({
73+
group: annotateId(entry.group, SUPPORTED_GROUPS),
74+
keyExchangeLength: entry.keyExchangeLength
75+
}))
76+
};
77+
default:
78+
return data;
79+
}
80+
}
81+
82+
export const tlsClientHello: HttpEndpoint = {
83+
matchPath: (path) => path === '/tls/client-hello',
84+
handle: (req, res) => {
85+
const helloData = getClientHello(req);
86+
87+
if (!helloData) {
88+
res.writeHead(500, { 'content-type': 'application/json' });
89+
res.end(JSON.stringify({ error: 'TLS client hello data not available' }));
90+
return;
91+
}
92+
93+
res.writeHead(200, { 'content-type': 'application/json' });
94+
res.end(JSON.stringify({
95+
version: annotateId(helloData.version, TLS_VERSIONS),
96+
random: helloData.random.toString('hex'),
97+
sessionId: helloData.sessionId.length > 0
98+
? helloData.sessionId.toString('hex')
99+
: null,
100+
cipherSuites: annotateIds(helloData.cipherSuites, CIPHER_SUITES),
101+
compressionMethods: annotateIds(helloData.compressionMethods, COMPRESSION_METHODS),
102+
extensions: helloData.extensions.map(ext => ({
103+
id: ext.id,
104+
name: EXTENSIONS[ext.id] ?? null,
105+
data: annotateExtensionData(ext.id, ext.data)
106+
}))
107+
}));
108+
},
109+
meta: {
110+
path: '/tls/client-hello',
111+
description: 'Returns the fully parsed TLS ClientHello. Requires HTTPS.',
112+
examples: ['/tls/client-hello']
113+
}
114+
};

src/endpoints/http/tls-fingerprint.ts

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,18 @@
1-
import { TLSSocket } from 'tls';
2-
import { HttpEndpoint, HttpRequest } from '../http-index.js';
3-
import { TLS_CLIENT_HELLO } from '../../tls-client-hello.js';
4-
5-
function getTlsSocket(req: HttpRequest): TLSSocket | undefined {
6-
// HTTP/1: socket is directly available
7-
if (req.socket instanceof TLSSocket) {
8-
return req.socket;
9-
}
10-
11-
// HTTP/2: socket is on the session
12-
const stream = (req as any).stream;
13-
const session = stream?.session;
14-
const socket = session?.socket;
15-
if (socket instanceof TLSSocket) {
16-
return socket;
17-
}
18-
19-
return undefined;
20-
}
1+
import { HttpEndpoint } from '../http-index.js';
2+
import { getClientHello } from '../../tls-client-hello.js';
213

224
export const tlsFingerprint: HttpEndpoint = {
235
matchPath: (path) => path === '/tls/fingerprint',
246
handle: (req, res) => {
25-
const tlsSocket = getTlsSocket(req);
7+
const helloData = getClientHello(req);
268

27-
if (!tlsSocket) {
9+
if (!helloData) {
2810
res.writeHead(400, { 'content-type': 'application/json' });
2911
res.end(JSON.stringify({ error: 'Not a TLS connection' }));
3012
return;
3113
}
3214

33-
const helloData = tlsSocket[TLS_CLIENT_HELLO];
34-
35-
if (!helloData?.ja3 || !helloData?.ja4) {
15+
if (!helloData.ja3 || !helloData.ja4) {
3616
res.writeHead(500, { 'content-type': 'application/json' });
3717
res.end(JSON.stringify({ error: 'TLS fingerprint not available' }));
3818
return;

src/http-handler.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { HttpRequest, HttpResponse } from './endpoints/http-index.js';
88
import { handleWebSocketUpgrade } from './ws-handler.js';
99
import { resolveEndpointChain } from './endpoint-chain.js';
1010
import { getDocsHtml } from './docs-page.js';
11-
import { TLS_CLIENT_HELLO } from './tls-client-hello.js';
11+
import { getClientHello } from './tls-client-hello.js';
1212

1313
function stopRawDataCapture(req: HttpRequest): void {
1414
if (req.httpVersion === '2.0') {
@@ -76,9 +76,9 @@ function createHttpRequestHandler(options: {
7676
const authority = req.headers[':authority']?.toString();
7777
if (authority) {
7878
const hostWithoutPort = authority.replace(/:\d+$/, '').toLowerCase();
79-
const tlsClientHello = socket.stream?.[TLS_CLIENT_HELLO];
80-
const sni = (tlsClientHello
81-
? getExtensionData(tlsClientHello, 'sni')?.serverName
79+
const clientHello = getClientHello(req);
80+
const sni = (clientHello
81+
? getExtensionData(clientHello, 'sni')?.serverName
8282
: undefined
8383
)?.toLowerCase();
8484

src/tls-client-hello.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,26 @@
1+
import { TLSSocket } from 'tls';
12
import type { TlsClientHelloMessage } from 'read-tls-client-hello';
3+
import type { HttpRequest } from './endpoints/http-index.js';
24

35
export const TLS_CLIENT_HELLO: unique symbol = Symbol('tlsClientHello');
46

57
export interface TlsClientHelloData extends TlsClientHelloMessage {
68
ja3: string;
79
ja4: string;
810
}
11+
12+
export function getClientHello(req: HttpRequest): TlsClientHelloData | undefined {
13+
// HTTP/1: socket is directly available
14+
if (req.socket instanceof TLSSocket) {
15+
return req.socket[TLS_CLIENT_HELLO];
16+
}
17+
18+
// HTTP/2: socket is on the session, wrapped in JSStreamSocket
19+
const stream = (req as any).stream;
20+
const socket = stream?.session?.socket;
21+
if (socket instanceof TLSSocket) {
22+
return socket[TLS_CLIENT_HELLO];
23+
}
24+
25+
return undefined;
26+
}

test/tls-client-hello.spec.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import * as net from 'net';
2+
import * as http from 'http';
3+
import * as https from 'https';
4+
import * as streamConsumers from 'stream/consumers';
5+
6+
import { expect } from 'chai';
7+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
8+
9+
import { createServer } from '../src/server.js';
10+
11+
describe("TLS client hello endpoint", () => {
12+
13+
let server: DestroyableServer;
14+
let serverPort: number;
15+
16+
beforeEach(async () => {
17+
server = makeDestroyable(await createServer());
18+
await new Promise<void>((resolve) => server.listen(resolve));
19+
serverPort = (server.address() as net.AddressInfo).port;
20+
});
21+
22+
afterEach(async () => {
23+
await server.destroy();
24+
});
25+
26+
it("returns the full annotated client hello", async () => {
27+
const response = await new Promise<any>((resolve, reject) => {
28+
https.get(`https://localhost:${serverPort}/tls/client-hello`, {
29+
rejectUnauthorized: false
30+
}, async (res) => {
31+
try {
32+
const body = await streamConsumers.text(res);
33+
resolve(JSON.parse(body));
34+
} catch (e) {
35+
reject(e);
36+
}
37+
}).on('error', reject);
38+
});
39+
40+
// Top-level structure
41+
expect(response).to.have.keys([
42+
'version', 'random', 'sessionId',
43+
'cipherSuites', 'compressionMethods', 'extensions'
44+
]);
45+
46+
// Version is annotated with id and name
47+
expect(response.version).to.deep.equal({ id: 0x0303, name: 'TLS 1.2' });
48+
49+
// Random is a 32-byte hex string
50+
expect(response.random).to.be.a('string').with.lengthOf(64);
51+
52+
// SessionId is a hex string or null
53+
expect(response.sessionId).to.satisfy(
54+
(v: any) => typeof v === 'string' || v === null
55+
);
56+
57+
// Cipher suites are annotated
58+
expect(response.cipherSuites).to.be.an('array').that.is.not.empty;
59+
for (const cipher of response.cipherSuites) {
60+
expect(cipher).to.have.property('id').that.is.a('number');
61+
expect(cipher).to.have.property('name');
62+
}
63+
64+
// Compression methods — modern TLS only sends null (0)
65+
expect(response.compressionMethods).to.deep.include({ id: 0, name: 'null' });
66+
67+
// Extensions are annotated
68+
expect(response.extensions).to.be.an('array').that.is.not.empty;
69+
for (const ext of response.extensions) {
70+
expect(ext).to.have.property('id').that.is.a('number');
71+
expect(ext).to.have.property('name');
72+
expect(ext).to.have.property('data');
73+
}
74+
});
75+
76+
it("includes server_name extension with correct hostname", async () => {
77+
const response = await new Promise<any>((resolve, reject) => {
78+
https.get(`https://localhost:${serverPort}/tls/client-hello`, {
79+
rejectUnauthorized: false
80+
}, async (res) => {
81+
try {
82+
const body = await streamConsumers.text(res);
83+
resolve(JSON.parse(body));
84+
} catch (e) {
85+
reject(e);
86+
}
87+
}).on('error', reject);
88+
});
89+
90+
const sniExt = response.extensions.find((e: any) => e.id === 0);
91+
expect(sniExt).to.exist;
92+
expect(sniExt.name).to.equal('server_name');
93+
expect(sniExt.data).to.deep.equal({ serverName: 'localhost' });
94+
});
95+
96+
it("annotates IDs within extension data", async () => {
97+
const response = await new Promise<any>((resolve, reject) => {
98+
https.get(`https://localhost:${serverPort}/tls/client-hello`, {
99+
rejectUnauthorized: false
100+
}, async (res) => {
101+
try {
102+
const body = await streamConsumers.text(res);
103+
resolve(JSON.parse(body));
104+
} catch (e) {
105+
reject(e);
106+
}
107+
}).on('error', reject);
108+
});
109+
110+
// supported_versions should have annotated version objects
111+
const versionsExt = response.extensions.find((e: any) => e.id === 43);
112+
expect(versionsExt).to.exist;
113+
expect(versionsExt.name).to.equal('supported_versions');
114+
expect(versionsExt.data.versions).to.be.an('array').that.is.not.empty;
115+
for (const v of versionsExt.data.versions) {
116+
expect(v).to.have.property('id').that.is.a('number');
117+
expect(v).to.have.property('name');
118+
}
119+
120+
// supported_groups should have annotated group objects
121+
const groupsExt = response.extensions.find((e: any) => e.id === 10);
122+
expect(groupsExt).to.exist;
123+
expect(groupsExt.data.groups).to.be.an('array').that.is.not.empty;
124+
for (const g of groupsExt.data.groups) {
125+
expect(g).to.have.property('id').that.is.a('number');
126+
expect(g).to.have.property('name');
127+
}
128+
129+
// signature_algorithms should have annotated algorithm objects
130+
const sigAlgsExt = response.extensions.find((e: any) => e.id === 13);
131+
expect(sigAlgsExt).to.exist;
132+
expect(sigAlgsExt.data.algorithms).to.be.an('array').that.is.not.empty;
133+
for (const a of sigAlgsExt.data.algorithms) {
134+
expect(a).to.have.property('id').that.is.a('number');
135+
expect(a).to.have.property('name');
136+
}
137+
});
138+
139+
it("returns 400 for non-TLS connections", async () => {
140+
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
141+
http.get(`http://localhost:${serverPort}/tls/client-hello`, async (res) => {
142+
try {
143+
const body = await streamConsumers.text(res);
144+
resolve({ status: res.statusCode!, body: JSON.parse(body) });
145+
} catch (e) {
146+
reject(e);
147+
}
148+
}).on('error', reject);
149+
});
150+
151+
expect(response.status).to.equal(400);
152+
expect(response.body.error).to.equal('Not a TLS connection');
153+
});
154+
155+
});

0 commit comments

Comments
 (0)