Skip to content

Commit 349b90b

Browse files
committed
Add support for extension names in getExtensionData
1 parent 8611e42 commit 349b90b

4 files changed

Lines changed: 77 additions & 38 deletions

File tree

README.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ GREASE values are preserved (they're what the client actually sent). The `calcul
5858

5959
Extensions with registered parsers return structured data. Flag extensions (like `extended_master_secret` or `signed_certificate_timestamp`) return `{}`. Unrecognized, unparseable or GREASE extensions return `null`. If an extension parser encounters malformed data, it falls back to `null` rather than failing the entire parse.
6060

61-
Parsed extensions include: `server_name`, `max_fragment_length`, `status_request`, `supported_groups`, `ec_point_formats`, `signature_algorithms`, `heartbeat`, `application_layer_protocol_negotiation`, `status_request_v2`, `signed_certificate_timestamp`, `padding`, `encrypt_then_mac`, `extended_master_secret`, `compress_certificate`, `record_size_limit`, `session_ticket`, `pre_shared_key`, `early_data`, `supported_versions`, `cookie`, `psk_key_exchange_modes`, `post_handshake_auth`, `signature_algorithms_cert`, `key_share`, `application_settings` (ALPS), `encrypted_client_hello` (ECH), and `renegotiation_info`.
61+
Parsed extensions include: `server_name` (SNI), `max_fragment_length`, `status_request`, `supported_groups`, `ec_point_formats`, `signature_algorithms`, `heartbeat`, `application_layer_protocol_negotiation` (ALPN), `status_request_v2`, `signed_certificate_timestamp`, `padding`, `encrypt_then_mac`, `extended_master_secret`, `compress_certificate`, `record_size_limit`, `session_ticket`, `pre_shared_key`, `early_data`, `supported_versions`, `cookie`, `psk_key_exchange_modes`, `post_handshake_auth`, `signature_algorithms_cert`, `key_share`, `application_settings` (ALPS), `encrypted_client_hello` (ECH), and `renegotiation_info`.
6262

6363
### TLS fingerprinting
6464

@@ -69,9 +69,35 @@ To calculate TLS fingerprints, there are a few options exported from this module
6969
* `calculateJa3(clientHello)` - Takes a parsed `TlsClientHelloMessage` and returns the corresponding JA3 hash.
7070
* `calculateJa4(clientHello)` - Takes a parsed `TlsClientHelloMessage` and returns the corresponding JA4 hash.
7171

72+
### Accessing extension data
73+
74+
Use `getExtensionData(extensions, id)` to look up a specific extension's parsed data by numeric ID, name, or alias. Returns the data object, or `null` if the extension is not present.
75+
76+
Names should be the officially registered name from https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml. Convenient aliases are provided for common cases, including `sni`, `alpn`, `alps` and `ech`. The API is typed so with TypeScript only valid names are allowed (although any raw numeric id can be used). PRs to add more aliases are welcome.
77+
78+
```javascript
79+
const { readTlsClientHello, getExtensionData, EXTENSION_IDS } = require('read-tls-client-hello');
80+
81+
const clientHello = await readTlsClientHello(socket);
82+
83+
// Get the server name (SNI)
84+
const sniData = getExtensionData(clientHello.extensions, 'sni'); // or 'server_name', or 0x0
85+
const serverName = sniData?.serverName;
86+
87+
// Get ALPN protocols
88+
const alpnData = getExtensionData(clientHello.extensions, 'alpn');
89+
const protocols = alpnData?.protocols;
90+
91+
// Get supported TLS versions
92+
const svData = getExtensionData(clientHello.extensions, 'supported_versions');
93+
const versions = svData?.versions; // e.g. [0x0304, 0x0303]
94+
```
95+
7296
### Lookup tables
7397
74-
All hello details (extensions, ciphers, etc) are exposed in `clientHello` with only numeric ids. To get human-readable names, lookup tables are provided:
98+
All hello details (extensions, ciphers, etc) are exposed with numeric IDs. Lookup tables map these to human-readable names. All tables are fully typed with `as const`, so known keys return literal values, which other keys' values may be undefined.
99+
100+
Forward tables (ID → name) for display:
75101

76102
* `TLS_VERSIONS` - e.g. `0x0303``'TLS 1.2'`
77103
* `CIPHER_SUITES` - e.g. `0x1301``'TLS_AES_128_GCM_SHA256'`
@@ -84,8 +110,12 @@ All hello details (extensions, ciphers, etc) are exposed in `clientHello` with o
84110
* `CERTIFICATE_COMPRESSION_ALGORITHMS` - e.g. `2``'brotli'`
85111
* `CERTIFICATE_STATUS_TYPES` - e.g. `1``'ocsp'`
86112

113+
A reverse table (name → ID) is also available for extensions:
114+
115+
* `EXTENSION_IDS` - e.g. `EXTENSION_IDS.key_share``51`, with aliases: `sni` (0), `alpn` (16), `alps` (17513), `ech` (65037)
116+
87117
```javascript
88-
const { CIPHER_SUITES, EXTENSIONS, isGREASE } = require('read-tls-client-hello');
118+
const { CIPHER_SUITES, isGREASE } = require('read-tls-client-hello');
89119
90120
const cipherNames = clientHello.cipherSuites
91121
.filter(id => !isGREASE(id))

src/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import * as tls from 'tls';
44
import * as net from 'net';
55

66
import { extensionParsers } from './extension-parsers';
7+
import { EXTENSION_IDS } from './lookup-tables';
8+
import type { ExtensionName } from './lookup-tables';
79

810
export { extensionParsers } from './extension-parsers';
911
export * from './lookup-tables';
@@ -71,8 +73,11 @@ export type TlsClientHelloMessage = {
7173
extensions: TlsExtension[];
7274
};
7375

74-
export function getExtensionData(extensions: TlsExtension[], id: number) {
75-
return extensions.find(e => e.id === id)?.data ?? null;
76+
export function getExtensionData(extensions: TlsExtension[], id: number | ExtensionName) {
77+
const numId = typeof id === 'string'
78+
? EXTENSION_IDS[id]
79+
: id;
80+
return extensions.find(e => e.id === numId)?.data ?? null;
7681
}
7782

7883
/**

src/lookup-tables.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,8 @@ export const CIPHER_SUITES = lookupTable({
392392
});
393393

394394
// https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml
395-
export const EXTENSIONS = lookupTable({
395+
// Internal const object — the single source of truth for both EXTENSIONS and EXTENSION_IDS.
396+
const _extensions = {
396397
0: 'server_name',
397398
1: 'max_fragment_length',
398399
2: 'client_certificate_url',
@@ -459,7 +460,31 @@ export const EXTENSIONS = lookupTable({
459460
65281: 'renegotiation_info',
460461
// Non-IANA but widely deployed
461462
17513: 'application_settings',
462-
});
463+
} as const satisfies Record<number, string>;
464+
465+
export const EXTENSIONS: LookupTable<typeof _extensions> = _extensions as LookupTable<typeof _extensions>;
466+
467+
// Reverse mapping: extension name (or alias) → numeric ID, derived from _extensions.
468+
type ExtKey = keyof typeof _extensions;
469+
type ExtVal = (typeof _extensions)[ExtKey];
470+
type ReverseExtensions = { readonly [V in ExtVal]: { [K in ExtKey]: (typeof _extensions)[K] extends V ? K : never }[ExtKey] };
471+
472+
const _extensionAliases = {
473+
sni: 0,
474+
alpn: 16,
475+
alps: 17513,
476+
ech: 65037,
477+
} as const satisfies Record<string, number>;
478+
479+
export const EXTENSION_IDS: ReverseExtensions & typeof _extensionAliases & Record<string, number | undefined> =
480+
Object.assign(
481+
Object.fromEntries(
482+
Object.entries(_extensions).map(([id, name]) => [name, Number(id)])
483+
),
484+
_extensionAliases
485+
) as any;
486+
487+
export type ExtensionName = keyof (ReverseExtensions & typeof _extensionAliases);
463488

464489
// https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-8
465490
export const SUPPORTED_GROUPS = lookupTable({

test/test.spec.ts

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -799,51 +799,30 @@ describe("Lookup tables", () => {
799799

800800
describe("getExtensionData", () => {
801801

802-
it("retrieves SNI from a Chrome ClientHello", async () => {
802+
it("retrieves extensions by alias", async () => {
803803
const incomingData = fs.createReadStream(path.join(__dirname, 'fixtures', 'chrome-tls-connect.bin'));
804804
const clientHello = await readTlsClientHello(incomingData);
805805

806-
const sni = getExtensionData(clientHello.extensions, 0x0000) as { serverName: string };
806+
const sni = getExtensionData(clientHello.extensions, 'sni') as { serverName: string };
807807
expect(sni.serverName).to.equal('localhost');
808+
809+
const alpn = getExtensionData(clientHello.extensions, 'alpn') as { protocols: string[] };
810+
expect(alpn.protocols).to.deep.equal(['h2', 'http/1.1']);
808811
});
809812

810-
it("retrieves ALPN from a Chrome ClientHello", async () => {
813+
it("retrieves extensions by IDs", async () => {
811814
const incomingData = fs.createReadStream(path.join(__dirname, 'fixtures', 'chrome-tls-connect.bin'));
812815
const clientHello = await readTlsClientHello(incomingData);
813816

814-
const alpn = getExtensionData(clientHello.extensions, 0x0010) as { protocols: string[] };
815-
expect(alpn.protocols).to.deep.equal(['h2', 'http/1.1']);
817+
const sni = getExtensionData(clientHello.extensions, 0x0) as { serverName: string };
818+
expect(sni.serverName).to.equal('localhost');
816819
});
817820

818821
it("returns null for absent extensions", async () => {
819822
const incomingData = fs.createReadStream(path.join(__dirname, 'fixtures', 'chrome-tls-connect.bin'));
820823
const clientHello = await readTlsClientHello(incomingData);
821-
822-
// heartbeat is not present in the Chrome fixture
823-
expect(getExtensionData(clientHello.extensions, 0x000F)).to.equal(null);
824+
expect(getExtensionData(clientHello.extensions, 'heartbeat')).to.equal(null);
825+
expect(getExtensionData(clientHello.extensions, 98765)).to.equal(null);
824826
});
825827

826-
let server: net.Server & { destroy?: () => Promise<void> };
827-
afterEach(() => server?.destroy?.().catch(() => {}));
828-
829-
it("returns null for SNI/ALPN when not present", async () => {
830-
const netServer = makeDestroyable(new net.Server());
831-
server = netServer;
832-
833-
netServer.listen();
834-
await new Promise((resolve) => netServer.on('listening', resolve));
835-
836-
let incomingSocketPromise = getDeferred<net.Socket>();
837-
netServer.on('connection', (socket) => incomingSocketPromise.resolve(socket));
838-
839-
const port = (netServer.address() as net.AddressInfo).port;
840-
tls.connect({ host: 'localhost', port }).on('error', () => {});
841-
842-
const incomingSocket = await incomingSocketPromise;
843-
const clientHello = await readTlsClientHello(incomingSocket);
844-
845-
// Pure TLS connection without SNI or ALPN
846-
expect(getExtensionData(clientHello.extensions, 0x0000)).to.equal(null);
847-
expect(getExtensionData(clientHello.extensions, 0x0010)).to.equal(null);
848-
});
849828
});

0 commit comments

Comments
 (0)