diff --git a/AGENTS.md b/AGENTS.md index ad7b06d..9b07502 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ src/browser/ "/browser": storage.ts (web/memory/custom), device.ts ( adapters.ts (signalFetch, mergeSignalHeaders, axiosSignals, kySignals, apolloSignals, signalStore) src/react.ts "/react": useDeviceSignals (React is an optional peer) src/server/ "/server": signals.ts (readSignals, bindSignals, checkSignals, decide), ip.ts (clientIp), + tls.ts (readTlsFingerprint: JA4/JA3 from a trusted proxy header), headers.ts (securityHeaders, corsPolicy), redact.ts (redact, maskEmail, maskPhone), adapters.ts (requestSecurity, fetchSecurity, expressSecurity, fastifySecurity) scripts/test.ts every part; browser code runs on simulated environments diff --git a/README.md b/README.md index 1147067..516bd16 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Device signals collected in any frontend and checked on the backend, plus the server helpers every endpoint needs. Zero dependencies. -![version](https://img.shields.io/badge/version-0.2.0-E9A23B) ![checks](https://img.shields.io/badge/checks-44-14264A) ![dependencies](https://img.shields.io/badge/dependencies-0-14264A) +![version](https://img.shields.io/badge/version-0.3.0-E9A23B) ![checks](https://img.shields.io/badge/checks-47-14264A) ![dependencies](https://img.shields.io/badge/dependencies-0-14264A) @@ -21,7 +21,7 @@ npm install @mgpl-diagnostics/security-kit | --- | --- | --- | | `@mgpl-diagnostics/security-kit` | everywhere (browser, Node.js, workers, React Native) | `DeviceSignals` types, `SIGNALS_HEADER`, `encodeSignals` / `decodeSignals` (validated), `randomId`, `sha256Hex`, `constantTimeEqual`, base64url, `canonicalJson` | | `@mgpl-diagnostics/security-kit/browser` | any frontend, any framework | `createSignalCollector`, `deviceInfo`, pluggable storage (`webStorage`, `memoryStorage`, or your own sync/async store) | -| `@mgpl-diagnostics/security-kit/server` | Node.js ≥ 20 | `readSignals`, `bindSignals`, `checkSignals`, `decide`; `clientIp` behind proxies; `securityHeaders`, `corsPolicy`; `redact`, `maskEmail`, `maskPhone`; adapters for Express, Fastify and Fetch-standard servers | +| `@mgpl-diagnostics/security-kit/server` | Node.js ≥ 20 | `readSignals`, `bindSignals`, `checkSignals`, `decide`; `clientIp` behind proxies; `readTlsFingerprint` (JA4/JA3 from the TLS proxy); `securityHeaders`, `corsPolicy`; `redact`, `maskEmail`, `maskPhone`; adapters for Express, Fastify and Fetch-standard servers | | `@mgpl-diagnostics/security-kit/react` | React ≥ 18 (optional peer) | `useDeviceSignals(collector)` | Both `/browser` and `/server` re-export the shared entry. @@ -51,7 +51,7 @@ dependency, and anything not listed uses `collector.headers()` or `subscribe` / | Fastify | `await app.register(fastifySecurity({ cors: { origins }, trustedHops: 1 }))` → `request.security` | | Express, Connect, node:http | `app.use(expressSecurity({ cors: { origins } }))` → `req.security` | | Hono, Next.js route handlers, Bun, Deno, workers, GraphQL Yoga | `fetchSecurity(opts).handle(request, remoteAddress)` → `{ security, headers, preflight }` | -| anything else, or a GraphQL context | `requestSecurity({ headers, remoteAddress, trustedHops })` → `{ ip, signals, userAgent, origin }` | +| anything else, or a GraphQL context | `requestSecurity({ headers, remoteAddress, trustedHops, tlsFingerprintHeader })` → `{ ip, signals, userAgent, origin, tlsFingerprint }` | `security.signals` is already validated (or `null`). Leave `cors` unset when another plugin (`@fastify/cors`, Yoga's own CORS) already handles it. @@ -79,6 +79,7 @@ sequenceDiagram | `fingerprint` | SHA-256 (first 32 hex) of: OS, browser family, mobile, `navigator.platform`, language, time zone, screen size, colour depth, CPU cores, device memory, touch points | sent only | | `device` | OS, browser and major version, mobile, language, time zone, screen size | sent only | | `app` | the app's version, if the app sets one | sent only | +| TLS fingerprint (server side) | JA4 or JA3 of the TLS ClientHello, computed by the TLS-terminating proxy, read by `readTlsFingerprint` | auth-kit stores a SHA-256 hash on the session | No canvas, audio, WebGL or font probing, no IP lookup, nothing sent to third parties. The browser version is left out of the fingerprint so updates don't change it. On the server, `bindSignals` stores @@ -138,6 +139,39 @@ if (!allow) endSession(); Signals are a second line of defence, not proof of identity: a determined attacker who steals a token can also copy the ids. They make silent token reuse from another browser visible and stoppable. +## TLS fingerprint + +A TLS fingerprint (JA4, or the older JA3) describes the client's TLS library from its ClientHello: which +ciphers, extensions and protocols it offers. No browser code can read it; whatever terminates TLS computes +it. In production that is the reverse proxy, which passes it on in a request header: + +```ts +import { readTlsFingerprint } from "@mgpl-diagnostics/security-kit/server"; + +const tls = readTlsFingerprint({ headers: req.headers, header: "x-ja4", trustedHops: 1 }); +// { kind: "ja4", value: "t13d1516h2_8daaf6152771_e5627efa2ab1" } or null +``` + +The adapters take the same option (`fastifySecurity({ trustedHops: 1, tlsFingerprintHeader: "x-ja4" })`) +and put the result on `security.tlsFingerprint`. + +The header gets the same trust model as X-Forwarded-For: it is read **only when `trustedHops` ≥ 1** and a +header name is configured, because without a trusted proxy any client could send it. The proxy must +**set** (overwrite) the header on every request, never pass on a value from the client. A value the proxy +appended to a client's arrives as `"a, b"` and is refused. Missing, malformed or oversized values give +`null`, never an error: TLS may be terminated by something that does not compute fingerprints. + +| Proxy | Set the header (`x-ja4` in these examples) | +| --- | --- | +| nginx | needs a JA4 module; with FoxIO's ja4-nginx: `proxy_set_header X-JA4 $http_ssl_ja4;` (`proxy_set_header` replaces a client's value) | +| Envoy | enable `enable_ja4_fingerprinting` on the `tls_inspector` listener filter, then `request_headers_to_add` `x-ja4: "%TLS_JA4_FINGERPRINT%"` with `append_action: OVERWRITE_IF_EXISTS_OR_ADD` (JA3: `%TLS_JA3_FINGERPRINT%`) | +| Amazon CloudFront | add `CloudFront-Viewer-JA4-Fingerprint` to the origin request policy, and use that as the header name | +| Cloudflare (Bot Management) | Transform Rule, *Set* request header `x-ja4` to `cf.bot_management.ja4` | + +Prefer JA4: Chrome shuffles its TLS extension order on every connection, so its JA3 changes constantly, +while JA4 sorts them. A fingerprint changes with browser updates and with middleboxes (corporate TLS +inspection, some mobile networks), so treat a change as a signal to log, not as proof of theft. + ## Server helpers ```ts diff --git a/llms-full.txt b/llms-full.txt index ff8e4f9..5da1fde 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @mgpl-diagnostics/security-kit — full documentation and source -> Generated by scripts/llms-full.mjs. Do not edit by hand. 24 files. +> Generated by scripts/llms-full.mjs. Do not edit by hand. 25 files. ## Contents @@ -24,6 +24,7 @@ - src/server/ip.ts - src/server/redact.ts - src/server/signals.ts +- src/server/tls.ts - src/shared/constants.ts - src/shared/crypto.ts - src/shared/signals.ts @@ -42,7 +43,7 @@ Device signals collected in any frontend and checked on the backend, plus the server helpers every endpoint needs. Zero dependencies. -![version](https://img.shields.io/badge/version-0.2.0-E9A23B) ![checks](https://img.shields.io/badge/checks-44-14264A) ![dependencies](https://img.shields.io/badge/dependencies-0-14264A) +![version](https://img.shields.io/badge/version-0.3.0-E9A23B) ![checks](https://img.shields.io/badge/checks-47-14264A) ![dependencies](https://img.shields.io/badge/dependencies-0-14264A) @@ -57,7 +58,7 @@ npm install @mgpl-diagnostics/security-kit | --- | --- | --- | | `@mgpl-diagnostics/security-kit` | everywhere (browser, Node.js, workers, React Native) | `DeviceSignals` types, `SIGNALS_HEADER`, `encodeSignals` / `decodeSignals` (validated), `randomId`, `sha256Hex`, `constantTimeEqual`, base64url, `canonicalJson` | | `@mgpl-diagnostics/security-kit/browser` | any frontend, any framework | `createSignalCollector`, `deviceInfo`, pluggable storage (`webStorage`, `memoryStorage`, or your own sync/async store) | -| `@mgpl-diagnostics/security-kit/server` | Node.js ≥ 20 | `readSignals`, `bindSignals`, `checkSignals`, `decide`; `clientIp` behind proxies; `securityHeaders`, `corsPolicy`; `redact`, `maskEmail`, `maskPhone`; adapters for Express, Fastify and Fetch-standard servers | +| `@mgpl-diagnostics/security-kit/server` | Node.js ≥ 20 | `readSignals`, `bindSignals`, `checkSignals`, `decide`; `clientIp` behind proxies; `readTlsFingerprint` (JA4/JA3 from the TLS proxy); `securityHeaders`, `corsPolicy`; `redact`, `maskEmail`, `maskPhone`; adapters for Express, Fastify and Fetch-standard servers | | `@mgpl-diagnostics/security-kit/react` | React ≥ 18 (optional peer) | `useDeviceSignals(collector)` | Both `/browser` and `/server` re-export the shared entry. @@ -87,7 +88,7 @@ dependency, and anything not listed uses `collector.headers()` or `subscribe` / | Fastify | `await app.register(fastifySecurity({ cors: { origins }, trustedHops: 1 }))` → `request.security` | | Express, Connect, node:http | `app.use(expressSecurity({ cors: { origins } }))` → `req.security` | | Hono, Next.js route handlers, Bun, Deno, workers, GraphQL Yoga | `fetchSecurity(opts).handle(request, remoteAddress)` → `{ security, headers, preflight }` | -| anything else, or a GraphQL context | `requestSecurity({ headers, remoteAddress, trustedHops })` → `{ ip, signals, userAgent, origin }` | +| anything else, or a GraphQL context | `requestSecurity({ headers, remoteAddress, trustedHops, tlsFingerprintHeader })` → `{ ip, signals, userAgent, origin, tlsFingerprint }` | `security.signals` is already validated (or `null`). Leave `cors` unset when another plugin (`@fastify/cors`, Yoga's own CORS) already handles it. @@ -115,6 +116,7 @@ sequenceDiagram | `fingerprint` | SHA-256 (first 32 hex) of: OS, browser family, mobile, `navigator.platform`, language, time zone, screen size, colour depth, CPU cores, device memory, touch points | sent only | | `device` | OS, browser and major version, mobile, language, time zone, screen size | sent only | | `app` | the app's version, if the app sets one | sent only | +| TLS fingerprint (server side) | JA4 or JA3 of the TLS ClientHello, computed by the TLS-terminating proxy, read by `readTlsFingerprint` | auth-kit stores a SHA-256 hash on the session | No canvas, audio, WebGL or font probing, no IP lookup, nothing sent to third parties. The browser version is left out of the fingerprint so updates don't change it. On the server, `bindSignals` stores @@ -174,6 +176,39 @@ if (!allow) endSession(); Signals are a second line of defence, not proof of identity: a determined attacker who steals a token can also copy the ids. They make silent token reuse from another browser visible and stoppable. +## TLS fingerprint + +A TLS fingerprint (JA4, or the older JA3) describes the client's TLS library from its ClientHello: which +ciphers, extensions and protocols it offers. No browser code can read it; whatever terminates TLS computes +it. In production that is the reverse proxy, which passes it on in a request header: + +```ts +import { readTlsFingerprint } from "@mgpl-diagnostics/security-kit/server"; + +const tls = readTlsFingerprint({ headers: req.headers, header: "x-ja4", trustedHops: 1 }); +// { kind: "ja4", value: "t13d1516h2_8daaf6152771_e5627efa2ab1" } or null +``` + +The adapters take the same option (`fastifySecurity({ trustedHops: 1, tlsFingerprintHeader: "x-ja4" })`) +and put the result on `security.tlsFingerprint`. + +The header gets the same trust model as X-Forwarded-For: it is read **only when `trustedHops` ≥ 1** and a +header name is configured, because without a trusted proxy any client could send it. The proxy must +**set** (overwrite) the header on every request, never pass on a value from the client. A value the proxy +appended to a client's arrives as `"a, b"` and is refused. Missing, malformed or oversized values give +`null`, never an error: TLS may be terminated by something that does not compute fingerprints. + +| Proxy | Set the header (`x-ja4` in these examples) | +| --- | --- | +| nginx | needs a JA4 module; with FoxIO's ja4-nginx: `proxy_set_header X-JA4 $http_ssl_ja4;` (`proxy_set_header` replaces a client's value) | +| Envoy | enable `enable_ja4_fingerprinting` on the `tls_inspector` listener filter, then `request_headers_to_add` `x-ja4: "%TLS_JA4_FINGERPRINT%"` with `append_action: OVERWRITE_IF_EXISTS_OR_ADD` (JA3: `%TLS_JA3_FINGERPRINT%`) | +| Amazon CloudFront | add `CloudFront-Viewer-JA4-Fingerprint` to the origin request policy, and use that as the header name | +| Cloudflare (Bot Management) | Transform Rule, *Set* request header `x-ja4` to `cf.bot_management.ja4` | + +Prefer JA4: Chrome shuffles its TLS extension order on every connection, so its JA3 changes constantly, +while JA4 sorts them. A fingerprint changes with browser updates and with middleboxes (corporate TLS +inspection, some mobile networks), so treat a change as a signal to log, not as proof of theft. + ## Server helpers ```ts @@ -233,6 +268,7 @@ src/browser/ "/browser": storage.ts (web/memory/custom), device.ts ( adapters.ts (signalFetch, mergeSignalHeaders, axiosSignals, kySignals, apolloSignals, signalStore) src/react.ts "/react": useDeviceSignals (React is an optional peer) src/server/ "/server": signals.ts (readSignals, bindSignals, checkSignals, decide), ip.ts (clientIp), + tls.ts (readTlsFingerprint: JA4/JA3 from a trusted proxy header), headers.ts (securityHeaders, corsPolicy), redact.ts (redact, maskEmail, maskPhone), adapters.ts (requestSecurity, fetchSecurity, expressSecurity, fastifySecurity) scripts/test.ts every part; browser code runs on simulated environments @@ -286,9 +322,10 @@ All rights reserved (see LICENSE). Key facts for agents: - Works with any library: browser adapters signalFetch, mergeSignalHeaders, axiosSignals, kySignals, apolloSignals, signalStore, and collector.subscribe/getSnapshot; "./react" useDeviceSignals; server adapters requestSecurity, fetchSecurity, expressSecurity, fastifySecurity. No adapter imports its library. -- Entries: "." (shared: types, encode/decode, randomId, sha256Hex, constantTimeEqual), "./browser" (createSignalCollector, deviceInfo, storages), "./server" (readSignals, bindSignals, checkSignals, decide, clientIp, securityHeaders, corsPolicy, redact, maskEmail, maskPhone). +- Entries: "." (shared: types, encode/decode, randomId, sha256Hex, constantTimeEqual), "./browser" (createSignalCollector, deviceInfo, storages), "./server" (readSignals, bindSignals, checkSignals, decide, clientIp, readTlsFingerprint, securityHeaders, corsPolicy, redact, maskEmail, maskPhone). - The shared and browser parts use no Node.js APIs (checked by typecheck and scripts/check-dist.mjs). - decodeSignals / readSignals never throw and return null for anything malformed or oversized. +- readTlsFingerprint reads a JA4/JA3 TLS fingerprint from a header the TLS-terminating proxy sets, only when trustedHops >= 1 and a header name is configured (same trust model as X-Forwarded-For); null otherwise, never throws. - Token and session logic stays in auth-kit; security-kit only supplies signals and helpers. ## Docs @@ -307,6 +344,7 @@ Key facts for agents: - [src/browser/device.ts](src/browser/device.ts): device info and fingerprint inputs - [src/server/signals.ts](src/server/signals.ts): readSignals, bindSignals, checkSignals, decide - [src/server/ip.ts](src/server/ip.ts): clientIp +- [src/server/tls.ts](src/server/tls.ts): readTlsFingerprint, parseTlsFingerprint - [src/server/headers.ts](src/server/headers.ts): securityHeaders, corsPolicy - [src/server/redact.ts](src/server/redact.ts): redact, maskEmail, maskPhone - [scripts/test.ts](scripts/test.ts): all checks @@ -325,7 +363,7 @@ Key facts for agents: ```json { "name": "@mgpl-diagnostics/security-kit", - "version": "0.2.0", + "version": "0.3.0", "description": "MGPL Diagnostics security primitives for both sides: device signals (device id, tab id, coarse fingerprint, device info) collected in any frontend and checked on the backend against the session; client IP behind proxies; security and CORS headers; log redaction; random ids, SHA-256 and constant-time compare. Zero dependencies; works with any frontend or backend library (fetch, axios, ky, Apollo, TanStack Query, React, Vue, Svelte, Express, Fastify, Fetch-standard servers).", "type": "module", "license": "SEE LICENSE IN LICENSE", @@ -1024,12 +1062,39 @@ const sigValue = encodeSignals(good); await check("requestSecurity", () => { const r = requestSecurity({ headers: { "x-mgpl-signals": sigValue, "user-agent": "UA", origin: "https://a.b" }, remoteAddress: "::ffff:10.1.1.1" }); - eq(r, { ip: "10.1.1.1", signals: good, userAgent: "UA", origin: "https://a.b" }); + eq(r, { ip: "10.1.1.1", signals: good, userAgent: "UA", origin: "https://a.b", tlsFingerprint: null }); +}); + +// ---------- server: TLS fingerprint from the proxy ---------- + +const { parseTlsFingerprint, readTlsFingerprint } = await import("../src/server/index"); +const ja4 = "t13d1516h2_8daaf6152771_e5627efa2ab1"; +const ja3 = "773906b0efdefa24a7f2b8eb6985bf37"; + +await check("parseTlsFingerprint: JA4, JA3, other forms; rejects junk", () => { + eq(parseTlsFingerprint(ja4), { kind: "ja4", value: ja4 }); + eq(parseTlsFingerprint(` ${ja4.toUpperCase()} `), { kind: "ja4", value: ja4 }); + eq(parseTlsFingerprint("q13i0310h3_55b375c5d22e_cd85d2d88918"), { kind: "ja4", value: "q13i0310h3_55b375c5d22e_cd85d2d88918" }); + eq(parseTlsFingerprint(ja3.toUpperCase()), { kind: "ja3", value: ja3 }); + eq(parseTlsFingerprint("771,4865-4866,0-23-65281,29-23,0"), { kind: "other", value: "771,4865-4866,0-23-65281,29-23,0" }); + for (const bad of [undefined, null, "", "short", "has space here", "