Skip to content

Commit 17bc76e

Browse files
forgetsoclaude
andauthored
feat(provider): read handshake timings as microseconds, not milliseconds (#2808)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4e77fa8 commit 17bc76e

11 files changed

Lines changed: 114 additions & 97 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@prosopo/provider": patch
3+
"@prosopo/types": patch
4+
"@prosopo/types-database": patch
5+
---
6+
7+
feat: switch handshake timings from milliseconds to microseconds
8+
9+
Milliseconds bucket fast handshakes (local proxies, same-DC clients) to 0/1 and destroy the distribution shape we need for proxy detection. `time.Now()` on Linux is ~1μs precise via vDSO — μs is the honest resolution ceiling.
10+
11+
Wire changes (must land together with the paired chaddy release):
12+
13+
- Headers consumed by `handshakeTimingMiddleware`: `x-tls-tcp-to-chello-ms` / `x-tls-chello-to-handshake-ms``x-tls-tcp-to-chello-us` / `x-tls-chello-to-handshake-us`.
14+
- Request augmentation, `HandshakeTiming` fields, decision-machine input, `Session` shape (Zod + Mongoose schemas): `tcpToChelloMs` / `chelloToHandshakeMs``tcpToChelloUs` / `chelloToHandshakeUs`.
15+
- New sessions in `captchastorage.sessions` will now write `tcpToChelloUs` / `chelloToHandshakeUs` in μs. Historical `*Ms` fields on existing session records remain as-is (not migrated) — analytics that read both must range-scan by field name.
16+
17+
Rollout: deploy paired chaddy image (emits `-Us` headers) simultaneously; the deploy-order window drops timing signal but no data corruption is possible (mismatched header names simply resolve to `undefined`).

packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -287,11 +287,11 @@ export default (
287287
// Timing values are per-connection so they come from
288288
// the current request even in the dedup replay path —
289289
// dedup.session was created on a different TCP conn.
290-
...(req.tcpToChelloMs !== undefined && {
291-
tcpToChelloMs: req.tcpToChelloMs,
290+
...(req.tcpToChelloUs !== undefined && {
291+
tcpToChelloUs: req.tcpToChelloUs,
292292
}),
293-
...(req.chelloToHandshakeMs !== undefined && {
294-
chelloToHandshakeMs: req.chelloToHandshakeMs,
293+
...(req.chelloToHandshakeUs !== undefined && {
294+
chelloToHandshakeUs: req.chelloToHandshakeUs,
295295
}),
296296
// currentUrl uses the cached session's value to
297297
// match the rest of the dedup routing input (score,
@@ -388,11 +388,11 @@ export default (
388388
sessionMode,
389389
userSitekeyIpHash,
390390
logger: req.logger,
391-
...(req.tcpToChelloMs !== undefined && {
392-
tcpToChelloMs: req.tcpToChelloMs,
391+
...(req.tcpToChelloUs !== undefined && {
392+
tcpToChelloUs: req.tcpToChelloUs,
393393
}),
394-
...(req.chelloToHandshakeMs !== undefined && {
395-
chelloToHandshakeMs: req.chelloToHandshakeMs,
394+
...(req.chelloToHandshakeUs !== undefined && {
395+
chelloToHandshakeUs: req.chelloToHandshakeUs,
396396
}),
397397
},
398398
res,
@@ -544,11 +544,11 @@ export default (
544544
...(entropyMathRandomFirst !== undefined && {
545545
entropyMathRandomFirst,
546546
}),
547-
...(req.tcpToChelloMs !== undefined && {
548-
tcpToChelloMs: req.tcpToChelloMs,
547+
...(req.tcpToChelloUs !== undefined && {
548+
tcpToChelloUs: req.tcpToChelloUs,
549549
}),
550-
...(req.chelloToHandshakeMs !== undefined && {
551-
chelloToHandshakeMs: req.chelloToHandshakeMs,
550+
...(req.chelloToHandshakeUs !== undefined && {
551+
chelloToHandshakeUs: req.chelloToHandshakeUs,
552552
}),
553553
});
554554

@@ -570,11 +570,11 @@ export default (
570570
headers: flatHeaders,
571571
userAgent: safeUserAgent,
572572
...(req.ja4 && { ja4: req.ja4 }),
573-
...(req.tcpToChelloMs !== undefined && {
574-
tcpToChelloMs: req.tcpToChelloMs,
573+
...(req.tcpToChelloUs !== undefined && {
574+
tcpToChelloUs: req.tcpToChelloUs,
575575
}),
576-
...(req.chelloToHandshakeMs !== undefined && {
577-
chelloToHandshakeMs: req.chelloToHandshakeMs,
576+
...(req.chelloToHandshakeUs !== undefined && {
577+
chelloToHandshakeUs: req.chelloToHandshakeUs,
578578
}),
579579
...(currentUrl && { currentUrl }),
580580
},

packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/shortCircuit.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ export type ShortCircuitInput = {
4040
sessionMode: ModeEnum | undefined;
4141
userSitekeyIpHash: string;
4242
logger: Logger;
43-
tcpToChelloMs?: number;
44-
chelloToHandshakeMs?: number;
43+
tcpToChelloUs?: number;
44+
chelloToHandshakeUs?: number;
4545
};
4646

4747
// Bypasses the bot-detection decision machine when the sitekey is configured
@@ -71,11 +71,11 @@ export const runConfiguredCaptchaTypeShortCircuit = async (
7171
headers: input.flatHeaders,
7272
mode: input.sessionMode,
7373
userSitekeyIpHash: input.userSitekeyIpHash,
74-
...(input.tcpToChelloMs !== undefined && {
75-
tcpToChelloMs: input.tcpToChelloMs,
74+
...(input.tcpToChelloUs !== undefined && {
75+
tcpToChelloUs: input.tcpToChelloUs,
7676
}),
77-
...(input.chelloToHandshakeMs !== undefined && {
78-
chelloToHandshakeMs: input.chelloToHandshakeMs,
77+
...(input.chelloToHandshakeUs !== undefined && {
78+
chelloToHandshakeUs: input.chelloToHandshakeUs,
7979
}),
8080
};
8181

packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,11 @@ export default (env: ProviderEnvironment) =>
140140
userAgent,
141141
...(req.ja4 && { ja4: req.ja4 }),
142142
...(fingerprintProof && { fingerprintProof }),
143-
...(req.tcpToChelloMs !== undefined && {
144-
tcpToChelloMs: req.tcpToChelloMs,
143+
...(req.tcpToChelloUs !== undefined && {
144+
tcpToChelloUs: req.tcpToChelloUs,
145145
}),
146-
...(req.chelloToHandshakeMs !== undefined && {
147-
chelloToHandshakeMs: req.chelloToHandshakeMs,
146+
...(req.chelloToHandshakeUs !== undefined && {
147+
chelloToHandshakeUs: req.chelloToHandshakeUs,
148148
}),
149149
},
150150
});
@@ -188,8 +188,8 @@ export default (env: ProviderEnvironment) =>
188188
}
189189

190190
const escalation = await buildEscalation(tasks, result, challenge, {
191-
tcpToChelloMs: req.tcpToChelloMs,
192-
chelloToHandshakeMs: req.chelloToHandshakeMs,
191+
tcpToChelloUs: req.tcpToChelloUs,
192+
chelloToHandshakeUs: req.chelloToHandshakeUs,
193193
});
194194
const response: PowCaptchaSolutionResponse = {
195195
status: "ok",
@@ -237,8 +237,8 @@ export const buildEscalation = async (
237237
// belong to a different TCP connection made during the earlier
238238
// frictionless request).
239239
handshakeTiming?: {
240-
tcpToChelloMs?: number;
241-
chelloToHandshakeMs?: number;
240+
tcpToChelloUs?: number;
241+
chelloToHandshakeUs?: number;
242242
},
243243
): Promise<PowCaptchaSolutionEscalation | undefined> => {
244244
if (!result.verified || !result.routingOutput) return undefined;
@@ -296,8 +296,8 @@ export const buildEscalation = async (
296296
originSession.entropyWallClockOffsetMs,
297297
originSession.entropyMathRandomFirst,
298298
originSession.currentUrl,
299-
handshakeTiming?.tcpToChelloMs,
300-
handshakeTiming?.chelloToHandshakeMs,
299+
handshakeTiming?.tcpToChelloUs,
300+
handshakeTiming?.chelloToHandshakeUs,
301301
);
302302

303303
// Record the origin → escalation sessionId mapping so a /captcha/*

packages/provider/src/api/handshakeTimingMiddleware.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,17 @@ import type { ProviderEnvironment } from "@prosopo/types-env";
1919
import type { NextFunction, Request, Response } from "express";
2020

2121
// Header names forwarded by the chaddy Caddy plugin per-TLS-connection.
22-
// Both are server-observed millisecond deltas across the TLS handshake
22+
// Both are server-observed microsecond deltas across the TLS handshake
2323
// lifecycle. Elevated values indicate the client's ClientHello traversed
2424
// a proxy chain before reaching Caddy — the CH bytes only reach the
2525
// terminating TCP stack after every hop, so the deltas inflate with the
2626
// full client-to-exit RTT rather than just the last-mile RTT.
27-
const HEADER_TCP_TO_CHELLO = "x-tls-tcp-to-chello-ms";
28-
const HEADER_CHELLO_TO_HANDSHAKE = "x-tls-chello-to-handshake-ms";
27+
const HEADER_TCP_TO_CHELLO = "x-tls-tcp-to-chello-us";
28+
const HEADER_CHELLO_TO_HANDSHAKE = "x-tls-chello-to-handshake-us";
2929

3030
export interface HandshakeTiming {
31-
tcpToChelloMs?: number;
32-
chelloToHandshakeMs?: number;
31+
tcpToChelloUs?: number;
32+
chelloToHandshakeUs?: number;
3333
}
3434

3535
const parseTimingHeader = (
@@ -61,12 +61,12 @@ export const getHandshakeTiming = (
6161
): HandshakeTiming => {
6262
const log = logger ?? getLogger("info", "provider:handshake-timing");
6363
return {
64-
tcpToChelloMs: parseTimingHeader(
64+
tcpToChelloUs: parseTimingHeader(
6565
headers[HEADER_TCP_TO_CHELLO],
6666
log,
6767
HEADER_TCP_TO_CHELLO,
6868
),
69-
chelloToHandshakeMs: parseTimingHeader(
69+
chelloToHandshakeUs: parseTimingHeader(
7070
headers[HEADER_CHELLO_TO_HANDSHAKE],
7171
log,
7272
HEADER_CHELLO_TO_HANDSHAKE,
@@ -82,20 +82,20 @@ export const handshakeTimingMiddleware = (env: ProviderEnvironment) => {
8282
return async (req: Request, res: Response, next: NextFunction) => {
8383
try {
8484
const timing = getHandshakeTiming(req.headers, req.logger);
85-
if (timing.tcpToChelloMs !== undefined) {
86-
req.tcpToChelloMs = timing.tcpToChelloMs;
85+
if (timing.tcpToChelloUs !== undefined) {
86+
req.tcpToChelloUs = timing.tcpToChelloUs;
8787
}
88-
if (timing.chelloToHandshakeMs !== undefined) {
89-
req.chelloToHandshakeMs = timing.chelloToHandshakeMs;
88+
if (timing.chelloToHandshakeUs !== undefined) {
89+
req.chelloToHandshakeUs = timing.chelloToHandshakeUs;
9090
}
9191
if (
92-
timing.tcpToChelloMs !== undefined ||
93-
timing.chelloToHandshakeMs !== undefined
92+
timing.tcpToChelloUs !== undefined ||
93+
timing.chelloToHandshakeUs !== undefined
9494
) {
9595
req.logger = req.logger.with(
9696
{
97-
tcpToChelloMs: timing.tcpToChelloMs,
98-
chelloToHandshakeMs: timing.chelloToHandshakeMs,
97+
tcpToChelloUs: timing.tcpToChelloUs,
98+
chelloToHandshakeUs: timing.chelloToHandshakeUs,
9999
},
100100
"handshakeTiming",
101101
);

packages/provider/src/express.d.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ export interface AugmentedRequest {
2727
requestId?: string;
2828
ipInfo?: IPInfoResponse;
2929
// Per-TLS-connection handshake timings forwarded by the chaddy Caddy
30-
// plugin (X-TLS-TCP-To-Chello-Ms / X-TLS-Chello-To-Handshake-Ms).
30+
// plugin (X-TLS-TCP-To-Chello-Us / X-TLS-Chello-To-Handshake-Us).
3131
// Undefined when the request did not traverse a TLS-terminating
3232
// Caddy with the chaddy plugin (e.g. plaintext dev requests) or when
3333
// the plugin failed to capture timing for that connection.
34-
tcpToChelloMs?: number;
35-
chelloToHandshakeMs?: number;
34+
tcpToChelloUs?: number;
35+
chelloToHandshakeUs?: number;
3636
}
3737

3838
declare global {
@@ -48,8 +48,8 @@ declare global {
4848
logger: Logger;
4949
requestId?: string;
5050
ipInfo?: IPInfoResponse;
51-
tcpToChelloMs?: number;
52-
chelloToHandshakeMs?: number;
51+
tcpToChelloUs?: number;
52+
chelloToHandshakeUs?: number;
5353
}
5454
}
5555
}

packages/provider/src/tasks/frictionless/frictionlessTasks.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ export class FrictionlessManager extends CaptchaManager {
146146
entropyCryptoFingerprint: params.entropyCryptoFingerprint,
147147
entropyWallClockOffsetMs: params.entropyWallClockOffsetMs,
148148
entropyMathRandomFirst: params.entropyMathRandomFirst,
149-
tcpToChelloMs: params.tcpToChelloMs,
150-
chelloToHandshakeMs: params.chelloToHandshakeMs,
149+
tcpToChelloUs: params.tcpToChelloUs,
150+
chelloToHandshakeUs: params.chelloToHandshakeUs,
151151
};
152152
}
153153

@@ -188,8 +188,8 @@ export class FrictionlessManager extends CaptchaManager {
188188
entropyWallClockOffsetMs?: Session["entropyWallClockOffsetMs"],
189189
entropyMathRandomFirst?: Session["entropyMathRandomFirst"],
190190
currentUrl?: Session["currentUrl"],
191-
tcpToChelloMs?: Session["tcpToChelloMs"],
192-
chelloToHandshakeMs?: Session["chelloToHandshakeMs"],
191+
tcpToChelloUs?: Session["tcpToChelloUs"],
192+
chelloToHandshakeUs?: Session["chelloToHandshakeUs"],
193193
): Promise<Session> {
194194
const sessionRecord: Session = {
195195
sessionId: `${getSessionIDPrefix(this.config.host)}-${uuidv4()}`,
@@ -224,8 +224,8 @@ export class FrictionlessManager extends CaptchaManager {
224224
entropyCryptoFingerprint,
225225
entropyWallClockOffsetMs,
226226
entropyMathRandomFirst,
227-
tcpToChelloMs,
228-
chelloToHandshakeMs,
227+
tcpToChelloUs,
228+
chelloToHandshakeUs,
229229
};
230230

231231
await this.db.storeSessionRecord(sessionRecord);
@@ -364,8 +364,8 @@ export class FrictionlessManager extends CaptchaManager {
364364
effectiveParams.entropyWallClockOffsetMs,
365365
effectiveParams.entropyMathRandomFirst,
366366
effectiveParams.currentUrl,
367-
effectiveParams.tcpToChelloMs,
368-
effectiveParams.chelloToHandshakeMs,
367+
effectiveParams.tcpToChelloUs,
368+
effectiveParams.chelloToHandshakeUs,
369369
);
370370

371371
// Fire-and-forget served-counter writes. Skipped when there's no
@@ -435,8 +435,8 @@ export class FrictionlessManager extends CaptchaManager {
435435
effectiveParams.entropyWallClockOffsetMs,
436436
effectiveParams.entropyMathRandomFirst,
437437
effectiveParams.currentUrl,
438-
effectiveParams.tcpToChelloMs,
439-
effectiveParams.chelloToHandshakeMs,
438+
effectiveParams.tcpToChelloUs,
439+
effectiveParams.chelloToHandshakeUs,
440440
);
441441
}
442442

packages/provider/src/tests/unit/api/handshakeTimingMiddleware.unit.test.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,64 +33,64 @@ const mockLogger = (): Logger => {
3333
describe("getHandshakeTiming", () => {
3434
it("returns both values when both headers are present and parseable", () => {
3535
const headers: IncomingHttpHeaders = {
36-
"x-tls-tcp-to-chello-ms": "42",
37-
"x-tls-chello-to-handshake-ms": "137",
36+
"x-tls-tcp-to-chello-us": "42",
37+
"x-tls-chello-to-handshake-us": "137",
3838
};
3939
const result = getHandshakeTiming(headers, mockLogger());
40-
expect(result.tcpToChelloMs).toBe(42);
41-
expect(result.chelloToHandshakeMs).toBe(137);
40+
expect(result.tcpToChelloUs).toBe(42);
41+
expect(result.chelloToHandshakeUs).toBe(137);
4242
});
4343

44-
it("returns 0 for legitimate zero-ms measurements", () => {
44+
it("returns 0 for legitimate zero-us measurements", () => {
4545
const headers: IncomingHttpHeaders = {
46-
"x-tls-tcp-to-chello-ms": "0",
47-
"x-tls-chello-to-handshake-ms": "0",
46+
"x-tls-tcp-to-chello-us": "0",
47+
"x-tls-chello-to-handshake-us": "0",
4848
};
4949
const result = getHandshakeTiming(headers, mockLogger());
50-
expect(result.tcpToChelloMs).toBe(0);
51-
expect(result.chelloToHandshakeMs).toBe(0);
50+
expect(result.tcpToChelloUs).toBe(0);
51+
expect(result.chelloToHandshakeUs).toBe(0);
5252
});
5353

5454
it("returns undefined when a header is missing", () => {
5555
const headers: IncomingHttpHeaders = {
56-
"x-tls-tcp-to-chello-ms": "5",
56+
"x-tls-tcp-to-chello-us": "5",
5757
};
5858
const result = getHandshakeTiming(headers, mockLogger());
59-
expect(result.tcpToChelloMs).toBe(5);
60-
expect(result.chelloToHandshakeMs).toBeUndefined();
59+
expect(result.tcpToChelloUs).toBe(5);
60+
expect(result.chelloToHandshakeUs).toBeUndefined();
6161
});
6262

6363
it("returns undefined for both when neither header is present", () => {
6464
const headers: IncomingHttpHeaders = {};
6565
const result = getHandshakeTiming(headers, mockLogger());
66-
expect(result.tcpToChelloMs).toBeUndefined();
67-
expect(result.chelloToHandshakeMs).toBeUndefined();
66+
expect(result.tcpToChelloUs).toBeUndefined();
67+
expect(result.chelloToHandshakeUs).toBeUndefined();
6868
});
6969

7070
it("rejects non-numeric header values", () => {
7171
const headers: IncomingHttpHeaders = {
72-
"x-tls-tcp-to-chello-ms": "not-a-number",
73-
"x-tls-chello-to-handshake-ms": "137",
72+
"x-tls-tcp-to-chello-us": "not-a-number",
73+
"x-tls-chello-to-handshake-us": "137",
7474
};
7575
const result = getHandshakeTiming(headers, mockLogger());
76-
expect(result.tcpToChelloMs).toBeUndefined();
77-
expect(result.chelloToHandshakeMs).toBe(137);
76+
expect(result.tcpToChelloUs).toBeUndefined();
77+
expect(result.chelloToHandshakeUs).toBe(137);
7878
});
7979

8080
it("rejects negative values", () => {
8181
const headers: IncomingHttpHeaders = {
82-
"x-tls-tcp-to-chello-ms": "-3",
82+
"x-tls-tcp-to-chello-us": "-3",
8383
};
8484
const result = getHandshakeTiming(headers, mockLogger());
85-
expect(result.tcpToChelloMs).toBeUndefined();
85+
expect(result.tcpToChelloUs).toBeUndefined();
8686
});
8787

8888
it("takes the first value when the header is repeated (string[])", () => {
8989
const headers: IncomingHttpHeaders = {
90-
"x-tls-tcp-to-chello-ms": ["17", "99"],
90+
"x-tls-tcp-to-chello-us": ["17", "99"],
9191
};
9292
const result = getHandshakeTiming(headers, mockLogger());
93-
expect(result.tcpToChelloMs).toBe(17);
93+
expect(result.tcpToChelloUs).toBe(17);
9494
});
9595
});
9696

0 commit comments

Comments
 (0)