quico 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,403 @@
1
+ /*
2
+ * quico: a full HTTP stack in JavaScript
3
+ * Copyright 2025 colocohen
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at:
8
+ *
9
+ * https://github.com/colocohen/quico
10
+ */
11
+
12
+
13
+
14
+ import crypto from 'node:crypto';
15
+ import { sha12 } from './http_common.js';
16
+
17
+
18
+ // ============================================================
19
+ // Protocol-layer fingerprints: HTTP/2 and HTTP/3.
20
+ //
21
+ // The third layer, alongside the TLS fingerprints lemon-tls produces (JA4)
22
+ // and the header fingerprint in ja4h.js. Each answers a different question:
23
+ //
24
+ // JA4 which TLS library built the ClientHello
25
+ // JA4H which HTTP client wrote the headers
26
+ // here which HTTP/2 or HTTP/3 implementation set up the connection
27
+ //
28
+ // Their value is in DISAGREEING. A request whose TLS says BoringSSL, whose
29
+ // headers say Chrome, and whose SETTINGS frame says hyper-h2 is not Chrome,
30
+ // and no amount of User-Agent spoofing hides it. All three layers have to
31
+ // tell the same story, and each one a client fakes correctly is one more it
32
+ // has to keep in sync with the others.
33
+ //
34
+ // Naming: the HTTP/2 format below is the one published by Elad Shuster at
35
+ // Black Hat Europe 2017 from more than 10 million connections observed at
36
+ // Akamai's edge. It is a research format, not a licensed standard, and there
37
+ // is no reference implementation with terms attached — unlike JA4+, where
38
+ // everything except JA4 itself carries the FoxIO License. "Akamai" is their
39
+ // trademark, so the function is named for what it does.
40
+ // ============================================================
41
+
42
+
43
+
44
+
45
+ // ============================================================
46
+ // HTTP/2
47
+ // ============================================================
48
+
49
+ /**
50
+ * h2Fingerprint(observed) — the four-section HTTP/2 fingerprint.
51
+ *
52
+ * S[;] | WU | P[,] | PS[,]
53
+ *
54
+ * 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p <- Chrome
55
+ *
56
+ * S SETTINGS as ID:Value pairs, IN THE ORDER SENT. Which parameters are
57
+ * ABSENT matters as much as the values: Chrome sends only four of the
58
+ * six, omitting MAX_CONCURRENT_STREAMS and MAX_FRAME_SIZE entirely, so
59
+ * a client sending all six is not Chrome whatever it claims.
60
+ *
61
+ * WU The WINDOW_UPDATE increment on stream 0, or 0 if none was sent.
62
+ * Chrome sends 15663105 and Firefox 12517377 — browsers open large
63
+ * windows because they fetch dozens of resources at once, while
64
+ * libraries keep the 65535 default and send no WINDOW_UPDATE at all.
65
+ *
66
+ * P PRIORITY frames as StreamID:Exclusive:DependsOn:Weight, or 0.
67
+ * Mostly historical now: RFC 9218 deprecated the dependency tree, and
68
+ * modern Chrome sends no PRIORITY frames at all — it signals priority
69
+ * through the HEADERS frame and the `priority` header instead. The 0 IS
70
+ * the fingerprint. Firefox still sends them, so presence or absence
71
+ * remains a clean binary that has to match the claimed browser.
72
+ *
73
+ * PS Pseudo-header order. Chrome m,a,s,p — Firefox m,p,a,s —
74
+ * Safari m,s,p,a. The spec fixes that pseudo-headers come first but
75
+ * says nothing about their order among themselves, so each
76
+ * implementation picked one and kept it. This is the single strongest
77
+ * section: it is set deep in the network stack, and most HTTP libraries
78
+ * use an order matching no browser at all.
79
+ */
80
+ function h2Fingerprint(observed) {
81
+ observed = observed || {};
82
+
83
+ // Pairs in the order received. An object would not do: insertion order is
84
+ // an implementation detail, and the order IS the signal here.
85
+ var settings = Array.isArray(observed.settings) ? observed.settings : [];
86
+ var s = settings.map(function (p) { return p.id + ':' + p.value; }).join(';');
87
+
88
+ var wu = observed.windowUpdate ? String(observed.windowUpdate) : '0';
89
+
90
+ var priorities = Array.isArray(observed.priorities) ? observed.priorities : [];
91
+ var p = priorities.length
92
+ ? priorities.map(function (x) {
93
+ return x.streamId + ':' + (x.exclusive ? 1 : 0) + ':' +
94
+ x.dependsOn + ':' + x.weight;
95
+ }).join(',')
96
+ : '0';
97
+
98
+ var ps = Array.isArray(observed.pseudoHeaderOrder) && observed.pseudoHeaderOrder.length
99
+ ? observed.pseudoHeaderOrder.join(',')
100
+ : '0';
101
+
102
+ var raw = s + '|' + wu + '|' + p + '|' + ps;
103
+ return {
104
+ hash: sha12(raw),
105
+
106
+ // The MD5 of the raw string is the form published alongside this format,
107
+ // and it is what an external database is keyed by. Verified against an
108
+ // independent capture of Chrome: the same header set yields
109
+ // 52d84b11737d980aef856699f885ca86 there and here.
110
+ //
111
+ // Both are returned because they answer different questions. `hash` keeps
112
+ // every fingerprint on a request looking alike; `md5` is for matching
113
+ // anything outside quico, and without it a cross-check fails for no
114
+ // visible reason.
115
+ md5: crypto.createHash('md5').update(raw, 'utf8').digest('hex'),
116
+
117
+ raw: raw, s: s, wu: wu, p: p, ps: ps
118
+ };
119
+ }
120
+
121
+ /** ':method' -> 'm'. The four are the only pseudo-headers a request may carry. */
122
+ var PSEUDO_LETTER = { ':method': 'm', ':authority': 'a', ':scheme': 's', ':path': 'p' };
123
+
124
+ function pseudoHeaderOrder(names) {
125
+ var out = [];
126
+ for (var i = 0; i < (names || []).length; i++) {
127
+ var l = PSEUDO_LETTER[names[i]];
128
+ if (l) out.push(l);
129
+ }
130
+ return out;
131
+ }
132
+
133
+
134
+ // ============================================================
135
+ // HTTP/3 over QUIC
136
+ // ============================================================
137
+
138
+ /**
139
+ * h3Fingerprint(observed) — the same idea one layer down.
140
+ *
141
+ * QUIC transport parameters do for the transport what SETTINGS does for
142
+ * HTTP/2, and each implementation ships different defaults: Chrome asks for
143
+ * 15MB of connection data and 6291456 per stream — mirroring its HTTP/2
144
+ * INITIAL_WINDOW_SIZE — and a 30s idle timeout.
145
+ *
146
+ * NO PUBLISHED FORMAT EXISTS. Unlike the HTTP/2 string above, nobody has
147
+ * standardised how to serialise these, so the layout below is quico's own:
148
+ * the same four-section, pipe-delimited shape, with QUIC's equivalents in
149
+ * each slot.
150
+ *
151
+ * TP | H3_SETTINGS | ALPN | GREASE
152
+ *
153
+ * That means these values are comparable ACROSS TIME and between quico
154
+ * servers, but NOT against a third-party database — there is nothing to
155
+ * compare them to. Which is fine for the actual use: catching a client whose
156
+ * layers disagree with each other.
157
+ *
158
+ * A second caution: HTTP/3 fingerprinting is less settled than HTTP/2 in
159
+ * general. Anti-bot products have not caught up to HTTP/2-level detection
160
+ * here, and the parameters themselves are still moving.
161
+ */
162
+ function h3Fingerprint(observed) {
163
+ observed = observed || {};
164
+
165
+ // Transport parameters SORTED by identifier, not left in the order sent.
166
+ //
167
+ // This is the opposite of the HTTP/2 section above, and measurement is why.
168
+ // Chrome shuffles its QUIC transport parameters between connections: two
169
+ // requests moments apart produced
170
+ //
171
+ // 5:6291456;9:103;15:L0;1:30000;17:0;3:1472;...
172
+ // 8:100;3:1472;17:0;6:6291456;4:15728640;...
173
+ //
174
+ // — same parameters, same values, different order. Hashing them as sent
175
+ // gives a fingerprint that never repeats, which is worse than no fingerprint
176
+ // at all because it looks like it works.
177
+ //
178
+ // The shuffling is deliberate, and the same defensive idea as GREASE: it
179
+ // stops anyone treating the order as fixed. So the order is noise here, and
180
+ // sorting removes it — exactly as JA4 sorts TLS extensions and JA3, to its
181
+ // cost, does not.
182
+ //
183
+ // Sorted NUMERICALLY, since these are numbers and a string sort would put
184
+ // 17 before 3.
185
+ var tp = Array.isArray(observed.transportParams) ? observed.transportParams : [];
186
+ var t = tp.slice()
187
+ .sort(function (a, b) { return Number(a.id) - Number(b.id); })
188
+ .map(function (x) { return x.id + ':' + x.value; })
189
+ .join(';');
190
+
191
+ // HTTP/3's own SETTINGS frame — a different thing from the QUIC transport
192
+ // parameters above: QPACK table size, blocked streams,
193
+ // ENABLE_CONNECT_PROTOCOL.
194
+ //
195
+ // Left in the order sent, because unlike the transport parameters it IS
196
+ // stable: the same Chrome produced 1:65536;6:262144;7:100;51:1 in that order
197
+ // across every connection observed. Sorting it would throw away signal that
198
+ // is really there.
199
+ var h3s = Array.isArray(observed.h3Settings) ? observed.h3Settings : [];
200
+ var h = h3s.map(function (x) { return x.id + ':' + x.value; }).join(';');
201
+
202
+ // The connection-level flow-control window the client opened, mirroring the
203
+ // WINDOW_UPDATE section of the HTTP/2 string. Chrome asks for 15MB over TCP
204
+ // and a comparable figure over QUIC; libraries keep the default and ask for
205
+ // nothing, which is its own signal.
206
+ var wu = observed.windowUpdate ? String(observed.windowUpdate) : '0';
207
+
208
+ // Pseudo-header order — the strongest section of the HTTP/2 fingerprint, and
209
+ // no weaker here. Chrome sends m,a,s,p, Firefox m,p,a,s, Safari m,s,p,a;
210
+ // the order is a constant in each browser's source and most HTTP libraries
211
+ // match none of them.
212
+ var ps = Array.isArray(observed.pseudoHeaderOrder) && observed.pseudoHeaderOrder.length
213
+ ? observed.pseudoHeaderOrder.join(',')
214
+ : '0';
215
+
216
+ var raw = t + '|' + h + '|' + wu + '|' + ps;
217
+ return {
218
+ hash: sha12(raw),
219
+ md5: crypto.createHash('md5').update(raw, 'utf8').digest('hex'),
220
+ raw: raw,
221
+ tp: t, h3: h, wu: wu, ps: ps,
222
+
223
+ // GREASE stays OUTSIDE the hashed string, unlike the published HTTP/3
224
+ // captures that write a literal "GREASE" token at the position it was
225
+ // injected. Recording the position would be more informative, but only if
226
+ // the position is stable — and it is not: the same Chrome shuffles its
227
+ // transport parameters between connections, which is exactly why they are
228
+ // sorted above. A position that moves would put the noise straight back
229
+ // into the hash.
230
+ //
231
+ // So its PRESENCE is reported as a separate field, which is stable and is
232
+ // most of the signal: browsers send GREASE, most libraries do not.
233
+ grease: observed.grease ? '1' : '0'
234
+ };
235
+ }
236
+
237
+
238
+ // ============================================================
239
+ // Collection
240
+ // ============================================================
241
+
242
+ /**
243
+ * Watch an H2Connection and accumulate what the fingerprint needs.
244
+ *
245
+ * Everything here arrives in the first flight, before any request, which is
246
+ * the point: the fingerprint exists before the server has processed a single
247
+ * byte of content and can be acted on before anything is served.
248
+ *
249
+ * Returns a getter; call it once a request has arrived, so the pseudo-header
250
+ * order is known.
251
+ */
252
+ function observeH2(conn) {
253
+ var state = { settings: [], windowUpdate: 0, priorities: [], pseudoHeaderOrder: [] };
254
+
255
+ // Two arguments: the object keyed by name, and the ordered list. The list is
256
+ // the second one — the object cannot carry order, which is the whole point
257
+ // of this section.
258
+ conn.on('settings', function (settingsObj, settingsList) {
259
+ if (state.settings.length) return; // first SETTINGS only
260
+ var list = settingsList || [];
261
+ for (var i = 0; i < list.length; i++) {
262
+ state.settings.push({ id: list[i].id, value: list[i].value });
263
+ }
264
+ });
265
+
266
+ conn.on('window_update', function (streamId, increment) {
267
+ // Stream 0 only — the connection-level window. Per-stream updates are
268
+ // ordinary flow control and say nothing about the implementation.
269
+ if (streamId === 0 && !state.windowUpdate) state.windowUpdate = increment;
270
+ });
271
+
272
+ conn.on('priority', function (streamId, exclusive, dependsOn, weight) {
273
+ if (state.priorities.length >= 16) return; // bounded; a peer controls this
274
+ state.priorities.push({ streamId: streamId, exclusive: exclusive,
275
+ dependsOn: dependsOn, weight: weight });
276
+ });
277
+
278
+ conn.on('pseudo_order', function (names) {
279
+ if (state.pseudoHeaderOrder.length) return; // first request only
280
+ state.pseudoHeaderOrder = pseudoHeaderOrder(names);
281
+ });
282
+
283
+ return function () { return h2Fingerprint(state); };
284
+ }
285
+
286
+
287
+ /**
288
+ * Watch an H3Connection and its QUIC connection, and accumulate what the
289
+ * HTTP/3 fingerprint needs.
290
+ *
291
+ * The mirror of observeH2, with the parts QUIC puts in different places: the
292
+ * transport parameters arrive inside the TLS ClientHello and are read from the
293
+ * QUIC connection, while the HTTP/3 SETTINGS arrive on the control stream.
294
+ *
295
+ * Returns a getter, callable once the handshake is complete.
296
+ */
297
+ /**
298
+ * Is this a GREASE identifier?
299
+ *
300
+ * RFC 9287 reserves identifiers of the form 31 * N + 27 for QUIC, and RFC 9114
301
+ * uses the same construction for HTTP/3 settings. An endpoint sends one with a
302
+ * random identifier and a random value purely to check that the peer ignores
303
+ * what it does not recognise. Chrome does; most libraries do not.
304
+ *
305
+ * They MUST be stripped before hashing, for the same reason JA3 strips TLS
306
+ * GREASE: identifier and value are both random per connection, so keeping them
307
+ * yields a fingerprint that never repeats. What is kept is the single fact
308
+ * that GREASE was present, which is a stable property of the implementation —
309
+ * and, in practice, one of the cleaner browser-versus-library signals.
310
+ *
311
+ * The test is by MAGNITUDE, not by the 31N+27 formula, and that is deliberate.
312
+ * A QUIC varint holds up to 2^62-1, well past the 2^53 where a JavaScript
313
+ * number stops being exact — Chrome sent 4421552879925488000, which cannot be
314
+ * represented and arrives already rounded. Any modulo on it answers a question
315
+ * about the rounded value rather than the real one, and answers it wrongly.
316
+ *
317
+ * So: no real transport parameter or HTTP/3 setting is anywhere near that
318
+ * range. Every one currently defined fits in a couple of bytes, and the
319
+ * registry reserves the high space precisely so that GREASE can live there.
320
+ * Treating "absurdly large" as "reserved" is exact where the arithmetic
321
+ * cannot be.
322
+ */
323
+ // Every identifier assigned by RFC 9000, RFC 9114 and their extensions is
324
+ // small: the transport parameters run to 0x20, the HTTP/3 settings to 0x33,
325
+ // and the registered extensions (datagrams, version negotiation) sit below
326
+ // 0x100. 4096 leaves an order of magnitude of headroom above anything real.
327
+ var GREASE_FLOOR = 0x1000; // 4096
328
+
329
+ function isGrease(id) {
330
+ var n = Number(id);
331
+ if (!isFinite(n)) return false;
332
+
333
+ // BY MAGNITUDE, not by the 31N+27 formula.
334
+ //
335
+ // The formula is what the RFCs describe for reserving the identifier space,
336
+ // and it is the wrong test to apply here. Measured against what Chrome
337
+ // actually sends — 12583, 12584, 41284154305, 4421552879925488000 — not one
338
+ // satisfies it; their residues mod 31 are 1, 2, 6 and 8. Whatever Chrome
339
+ // uses to pick a reserved identifier, it is not that formula, and a check
340
+ // built on it lets every one of them into the hash along with its random
341
+ // value.
342
+ //
343
+ // What IS true of all four, and of no assigned identifier, is that they are
344
+ // enormous. That is the property to test.
345
+ return n >= GREASE_FLOOR;
346
+ }
347
+
348
+
349
+ function observeH3(h3, quic, existingState) {
350
+ // State may be handed in, because the two halves of an HTTP/3 connection
351
+ // become available at different times: the QUIC transport parameters arrive
352
+ // during the handshake, and the H3 control stream only afterwards. Passing
353
+ // the accumulated state forward lets a caller attach to QUIC early and to H3
354
+ // later without losing what the first pass collected.
355
+ var state = existingState ||
356
+ { transportParams: [], h3Settings: [], alpn: 'h3', grease: false,
357
+ windowUpdate: 0, pseudoHeaderOrder: [] };
358
+
359
+ if (h3 && h3.on) {
360
+ h3.on('pseudo_order', function (names) {
361
+ if (state.pseudoHeaderOrder.length) return; // first request only
362
+ state.pseudoHeaderOrder = pseudoHeaderOrder(names);
363
+ });
364
+
365
+ h3.on('peer_settings', function (ordered) {
366
+ if (state.h3Settings.length) return;
367
+ for (var i = 0; i < ordered.length; i++) {
368
+ if (isGrease(ordered[i].id)) { state.grease = true; continue; }
369
+ state.h3Settings.push({ id: ordered[i].id, value: ordered[i].value });
370
+ }
371
+ });
372
+ }
373
+
374
+ if (quic && quic.on) {
375
+ quic.on('peer_max_data', function (bytes) {
376
+ // initial_max_data from the transport parameters: the connection-level
377
+ // window, and the QUIC equivalent of an HTTP/2 WINDOW_UPDATE on stream 0.
378
+ if (!state.windowUpdate) state.windowUpdate = bytes;
379
+ });
380
+
381
+ quic.on('peer_transport_params', function (params) {
382
+ if (state.transportParams.length) return;
383
+ for (var k = 0; k < (params || []).length; k++) {
384
+ if (isGrease(params[k].id)) { state.grease = true; continue; }
385
+ // 0x04 is initial_max_data — the connection-level window. Captured
386
+ // into its own section as well as the parameter list, so the string
387
+ // has the same four-part shape as the HTTP/2 one.
388
+ if (Number(params[k].id) === 0x04 && !state.windowUpdate) {
389
+ state.windowUpdate = params[k].value;
390
+ }
391
+ state.transportParams.push({ id: params[k].id, value: params[k].value });
392
+ }
393
+ });
394
+ }
395
+
396
+ var getter = function () { return h3Fingerprint(state); };
397
+ // Exposed so a second call can continue accumulating into the same object.
398
+ getter.state = state;
399
+ return getter;
400
+ }
401
+
402
+
403
+ export { h2Fingerprint, h3Fingerprint, observeH2, observeH3, pseudoHeaderOrder, isGrease };