diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..e18a3fa --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Every change to this repo needs a review from a code owner (see CONTRIBUTING in mgpl-diagnostics/.github). +* @BackStacked diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fdd195c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + minor-and-patch: + update-types: [minor, patch] + ignore: + # our own packages are released together and bumped by hand + - dependency-name: "@mgpl-diagnostics/*" + - dependency-name: "typescript" + update-types: ["version-update:semver-major"] + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e76750c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The job id "ci" is the required status check on main (see mgpl-diagnostics/.github scripts/protect-repos.mjs). + ci: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + - run: npm install --no-package-lock + - run: npm run typecheck + - run: npm test + - run: npm run build + - name: Main and /browser bundles load no Node.js modules + run: node scripts/check-dist.mjs + - name: llms-full.txt is up to date (run `npm run llms` and commit if this fails) + run: npm run llms && git diff --exit-code -- llms-full.txt diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..68af1d8 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,34 @@ +name: Publish to GitHub Packages + +on: + push: + tags: ["v*"] + # the owner can also publish main's package.json version by hand (Actions → Run workflow) + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + # tags are admin-only; a manual run is limited to main and to the code owner + if: github.event_name == 'push' || (github.ref == 'refs/heads/main' && github.actor == 'BackStacked') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + registry-url: https://npm.pkg.github.com + scope: "@mgpl-diagnostics" + - name: Tag must match package.json version + if: github.event_name == 'push' + run: test "v$(node -p "require('./package.json').version")" = "${GITHUB_REF_NAME}" + # installs dependencies (incl. @mgpl-diagnostics/* peers from GitHub Packages) and builds dist/ via "prepare" + - run: npm install --no-package-lock + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8bb7be9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# AGENTS.md — @mgpl-diagnostics/security-kit + +Security primitives used on **both sides**: the frontend (`/browser`, through `@mgpl-diagnostics/client` +and apps) and the backend (`/server`, through `mgpl-diagnostics/api` and auth flows). **A library: no +server, no GraphQL, no UI, zero runtime dependencies.** + +## Map + +``` +src/index.ts shared entry (browser, Node.js, workers, React Native) +src/shared/types.ts DeviceSignals, DeviceInfo +src/shared/constants.ts SIGNALS_HEADER ("x-mgpl-signals"), SIGNAL_LIMITS, ID_PATTERN, FINGERPRINT_PATTERN +src/shared/crypto.ts base64url, randomBytes/randomId, sha256Hex, constantTimeEqual, canonicalJson (Web Crypto only) +src/shared/signals.ts encodeSignals, signalHeaders, parseSignals/decodeSignals (validate untrusted input) +src/browser/ "/browser": storage.ts (web/memory/custom), device.ts (deviceInfo, fingerprint inputs), + collect.ts (createSignalCollector) +src/server/ "/server": signals.ts (readSignals, bindSignals, checkSignals, decide), ip.ts (clientIp), + headers.ts (securityHeaders, corsPolicy), redact.ts (redact, maskEmail, maskPhone) +scripts/test.ts every part; browser code runs on simulated environments +scripts/check-dist.mjs the built main and /browser bundles import no Node.js modules +``` + +## Rules + +1. **The shared and browser parts use no Node.js APIs** (no `node:*`, no `Buffer`): only Web Crypto, + `TextEncoder`, `btoa`/`atob`. `npm run typecheck` compiles them without `@types/node`, and CI runs + `scripts/check-dist.mjs` on the bundles. +2. **No runtime dependencies.** Frameworks (React, TanStack Query, Fastify…) stay in the consumers. +3. **Signals stay coarse.** No canvas, audio, WebGL, font or IP probing, nothing sent to third parties. + Anything new that is collected goes in the README table "What is collected" in the same PR. +4. **Never store raw ids or tokens.** The server keeps `bindSignals()` output (SHA-256 of ids). +5. **Everything read from a request is untrusted.** `decodeSignals` / `readSignals` never throw, cap + sizes, and return `null` for anything malformed. Keep it that way and add a test for each new field. +6. The signal format is a contract between client and api: `SIGNALS_HEADER`, field names and `v: 1`. + Add optional fields; a breaking change needs `v: 2` and both versions accepted during the rollout. +7. Verdict names (`same`, `changed`, `other-device`, `missing`, `unbound`) are public: add, never rename. +8. Token logic (passwords, sessions, JWT) stays in **auth-kit**; permissions stay in auth-kit. +9. Never commit secrets, `.env` files or real patient data (tests use fictional values). + +## Commands + +```bash +npm run typecheck && npm test && npm run build && node scripts/check-dist.mjs +npm run llms # regenerate llms-full.txt (CI fails when it is stale) +``` + +## Repository rules + +`main` takes pull requests only: squash merge, 1 code-owner review (`@BackStacked`), the `ci` check +required. Rules come from `mgpl-diagnostics/.github` (`scripts/protect-repos.mjs`). + +## Licence + +All rights reserved (see LICENSE). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..e51d35e --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +
+ +# security-kit + +**Security primitives for both sides of MGPL Diagnostics.** +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.1.0-E9A23B) ![checks](https://img.shields.io/badge/checks-32-14264A) ![dependencies](https://img.shields.io/badge/dependencies-0-14264A) + +
+ +```bash +# .npmrc: @mgpl-diagnostics:registry=https://npm.pkg.github.com (a token with read:packages) +npm install @mgpl-diagnostics/security-kit +``` + +## Entries + +| Import | Runs in | What it has | +| --- | --- | --- | +| `@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` | + +Both `/browser` and `/server` re-export the shared entry. + +## Device signals + +```mermaid +sequenceDiagram + participant B as Browser (security-kit/browser) + participant A as api (security-kit/server + auth-kit) + B->>B: collect(): deviceId, tabId, fingerprint, device info + B->>A: login / refresh with header x-mgpl-signals + A->>A: readSignals(headers) (validated, size-capped) + Note over A: login: bindSignals(signals) is stored on the session + Note over A: refresh: checkSignals(binding, signals) then decide() + A-->>B: same: OK · changed: OK + audit · other-device: session ended +``` + +### What is collected + +| Field | Source | Kept where | +| --- | --- | --- | +| `deviceId` | 128-bit random id, created once | browser `localStorage` (`mgpl.deviceId`) | +| `tabId` | 128-bit random id per tab | browser `sessionStorage` (`mgpl.tabId`) | +| `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 | + +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 +**SHA-256 hashes of the ids**, never the ids themselves. + +### Browser + +```ts +import { createSignalCollector } from "@mgpl-diagnostics/security-kit/browser"; + +export const signals = createSignalCollector({ app: "lab-panel@1.4.0" }); + +// any fetch wrapper, GraphQL client or query library +const res = await fetch("/graphql", { + method: "POST", + headers: { "content-type": "application/json", ...(await signals.headers()) }, + body: JSON.stringify({ query, variables }), +}); + +await signals.reset(); // "forget this device", e.g. after signing out on a shared computer +``` + +React Native or other runtimes pass their own storage and environment: + +```ts +createSignalCollector({ + storage: { get: (k) => AsyncStorage.getItem(k), set: (k, v) => AsyncStorage.setItem(k, v), remove: (k) => AsyncStorage.removeItem(k) }, + tabStorage: memoryStorage(), + environment: () => ({ navigator: { userAgent: "MGPL-App/2.0 (Android 14)", language: "en-IN" }, screen: { width: 1080, height: 2400 } }), +}); +``` + +### Server + +```ts +import { bindSignals, checkSignals, decide, readSignals } from "@mgpl-diagnostics/security-kit/server"; + +// login: store the binding with the session +const signals = readSignals(request.headers); // null when missing or invalid +session.signals = signals ? bindSignals(signals) : null; + +// refresh: compare +const check = checkSignals(session.signals, readSignals(request.headers)); +const { allow, log } = decide(check, { rejectOtherDevice: true, requireSignals: false }); +if (log) audit({ action: "auth.signals_" + check.verdict, meta: { changes: check.changes } }); +if (!allow) endSession(); +``` + +| Verdict | Meaning | Default decision | +| --- | --- | --- | +| `same` | same device id and fingerprint (another tab or a browser update is still `same`) | allow | +| `changed` | same device id; fingerprint, OS, browser, form factor, language, time zone or screen changed | allow, log | +| `other-device` | a different device id: the token is used from another browser | **reject**, log | +| `missing` | the session is bound but the request sent no valid signals | allow, log (`requireSignals: true` rejects) | +| `unbound` | the session has no binding (older session or non-browser client) | allow | + +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. + +## Server helpers + +```ts +import { clientIp, corsPolicy, redact, securityHeaders } from "@mgpl-diagnostics/security-kit/server"; + +// behind one load balancer; X-Forwarded-For is ignored when trustedHops is 0 (the default) +const ip = clientIp({ remoteAddress: req.socket.remoteAddress, headers: req.headers, trustedHops: 1 }); + +const cors = corsPolicy({ origins: ["https://lab.example.in", "https://*.mgpl.example"] }); +reply.headers({ ...securityHeaders(), ...cors.headers(req.headers.origin, req.method === "OPTIONS") }); + +logger.info(redact({ body, headers })); // passwords, tokens, cookies, keys → "[redacted]" +``` + +- `securityHeaders()`: HSTS, `nosniff`, `DENY` framing, `no-referrer`, a locked-down CSP for a JSON API, + `Permissions-Policy`, `no-store`. Each can be changed or turned off (GraphiQL needs its own CSP). +- `corsPolicy()`: exact origins and `https://*.domain` patterns (never the bare domain, never another + scheme), `"*"` only without credentials; preflights always allow `x-mgpl-signals`. +- `redact()`: deep copy with secrets hidden (`password`, `*Token`, `*Secret`, `authorization`, `cookie`, + API keys, OTP…), bearer strings, `Headers`, `Map`, errors and cycles; add your own keys. + `maskEmail` / `maskPhone` for patient contact details in logs. + +## Develop + +```bash +npm run typecheck # also checks that the shared and browser parts compile without Node.js types +npm test # 32 checks +npm run build && node scripts/check-dist.mjs +npm run llms # regenerate llms-full.txt (CI fails when it is stale) +``` + +## Licence + +All rights reserved. See [LICENSE](LICENSE). diff --git a/llms-full.txt b/llms-full.txt new file mode 100644 index 0000000..62e5877 --- /dev/null +++ b/llms-full.txt @@ -0,0 +1,1748 @@ +# @mgpl-diagnostics/security-kit — full documentation and source + +> Generated by scripts/llms-full.mjs. Do not edit by hand. 21 files. + +## Contents + +- README.md +- AGENTS.md +- llms.txt +- package.json +- scripts/check-dist.mjs +- scripts/llms-full.mjs +- scripts/test.ts +- src/browser/collect.ts +- src/browser/device.ts +- src/browser/index.ts +- src/browser/storage.ts +- src/index.ts +- src/server/headers.ts +- src/server/index.ts +- src/server/ip.ts +- src/server/redact.ts +- src/server/signals.ts +- src/shared/constants.ts +- src/shared/crypto.ts +- src/shared/signals.ts +- src/shared/types.ts + + +--- + +## File: README.md + +
+ +# security-kit + +**Security primitives for both sides of MGPL Diagnostics.** +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.1.0-E9A23B) ![checks](https://img.shields.io/badge/checks-32-14264A) ![dependencies](https://img.shields.io/badge/dependencies-0-14264A) + +
+ +```bash +# .npmrc: @mgpl-diagnostics:registry=https://npm.pkg.github.com (a token with read:packages) +npm install @mgpl-diagnostics/security-kit +``` + +## Entries + +| Import | Runs in | What it has | +| --- | --- | --- | +| `@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` | + +Both `/browser` and `/server` re-export the shared entry. + +## Device signals + +```mermaid +sequenceDiagram + participant B as Browser (security-kit/browser) + participant A as api (security-kit/server + auth-kit) + B->>B: collect(): deviceId, tabId, fingerprint, device info + B->>A: login / refresh with header x-mgpl-signals + A->>A: readSignals(headers) (validated, size-capped) + Note over A: login: bindSignals(signals) is stored on the session + Note over A: refresh: checkSignals(binding, signals) then decide() + A-->>B: same: OK · changed: OK + audit · other-device: session ended +``` + +### What is collected + +| Field | Source | Kept where | +| --- | --- | --- | +| `deviceId` | 128-bit random id, created once | browser `localStorage` (`mgpl.deviceId`) | +| `tabId` | 128-bit random id per tab | browser `sessionStorage` (`mgpl.tabId`) | +| `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 | + +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 +**SHA-256 hashes of the ids**, never the ids themselves. + +### Browser + +```ts +import { createSignalCollector } from "@mgpl-diagnostics/security-kit/browser"; + +export const signals = createSignalCollector({ app: "lab-panel@1.4.0" }); + +// any fetch wrapper, GraphQL client or query library +const res = await fetch("/graphql", { + method: "POST", + headers: { "content-type": "application/json", ...(await signals.headers()) }, + body: JSON.stringify({ query, variables }), +}); + +await signals.reset(); // "forget this device", e.g. after signing out on a shared computer +``` + +React Native or other runtimes pass their own storage and environment: + +```ts +createSignalCollector({ + storage: { get: (k) => AsyncStorage.getItem(k), set: (k, v) => AsyncStorage.setItem(k, v), remove: (k) => AsyncStorage.removeItem(k) }, + tabStorage: memoryStorage(), + environment: () => ({ navigator: { userAgent: "MGPL-App/2.0 (Android 14)", language: "en-IN" }, screen: { width: 1080, height: 2400 } }), +}); +``` + +### Server + +```ts +import { bindSignals, checkSignals, decide, readSignals } from "@mgpl-diagnostics/security-kit/server"; + +// login: store the binding with the session +const signals = readSignals(request.headers); // null when missing or invalid +session.signals = signals ? bindSignals(signals) : null; + +// refresh: compare +const check = checkSignals(session.signals, readSignals(request.headers)); +const { allow, log } = decide(check, { rejectOtherDevice: true, requireSignals: false }); +if (log) audit({ action: "auth.signals_" + check.verdict, meta: { changes: check.changes } }); +if (!allow) endSession(); +``` + +| Verdict | Meaning | Default decision | +| --- | --- | --- | +| `same` | same device id and fingerprint (another tab or a browser update is still `same`) | allow | +| `changed` | same device id; fingerprint, OS, browser, form factor, language, time zone or screen changed | allow, log | +| `other-device` | a different device id: the token is used from another browser | **reject**, log | +| `missing` | the session is bound but the request sent no valid signals | allow, log (`requireSignals: true` rejects) | +| `unbound` | the session has no binding (older session or non-browser client) | allow | + +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. + +## Server helpers + +```ts +import { clientIp, corsPolicy, redact, securityHeaders } from "@mgpl-diagnostics/security-kit/server"; + +// behind one load balancer; X-Forwarded-For is ignored when trustedHops is 0 (the default) +const ip = clientIp({ remoteAddress: req.socket.remoteAddress, headers: req.headers, trustedHops: 1 }); + +const cors = corsPolicy({ origins: ["https://lab.example.in", "https://*.mgpl.example"] }); +reply.headers({ ...securityHeaders(), ...cors.headers(req.headers.origin, req.method === "OPTIONS") }); + +logger.info(redact({ body, headers })); // passwords, tokens, cookies, keys → "[redacted]" +``` + +- `securityHeaders()`: HSTS, `nosniff`, `DENY` framing, `no-referrer`, a locked-down CSP for a JSON API, + `Permissions-Policy`, `no-store`. Each can be changed or turned off (GraphiQL needs its own CSP). +- `corsPolicy()`: exact origins and `https://*.domain` patterns (never the bare domain, never another + scheme), `"*"` only without credentials; preflights always allow `x-mgpl-signals`. +- `redact()`: deep copy with secrets hidden (`password`, `*Token`, `*Secret`, `authorization`, `cookie`, + API keys, OTP…), bearer strings, `Headers`, `Map`, errors and cycles; add your own keys. + `maskEmail` / `maskPhone` for patient contact details in logs. + +## Develop + +```bash +npm run typecheck # also checks that the shared and browser parts compile without Node.js types +npm test # 32 checks +npm run build && node scripts/check-dist.mjs +npm run llms # regenerate llms-full.txt (CI fails when it is stale) +``` + +## Licence + +All rights reserved. See [LICENSE](LICENSE). + + +--- + +## File: AGENTS.md + +# AGENTS.md — @mgpl-diagnostics/security-kit + +Security primitives used on **both sides**: the frontend (`/browser`, through `@mgpl-diagnostics/client` +and apps) and the backend (`/server`, through `mgpl-diagnostics/api` and auth flows). **A library: no +server, no GraphQL, no UI, zero runtime dependencies.** + +## Map + +``` +src/index.ts shared entry (browser, Node.js, workers, React Native) +src/shared/types.ts DeviceSignals, DeviceInfo +src/shared/constants.ts SIGNALS_HEADER ("x-mgpl-signals"), SIGNAL_LIMITS, ID_PATTERN, FINGERPRINT_PATTERN +src/shared/crypto.ts base64url, randomBytes/randomId, sha256Hex, constantTimeEqual, canonicalJson (Web Crypto only) +src/shared/signals.ts encodeSignals, signalHeaders, parseSignals/decodeSignals (validate untrusted input) +src/browser/ "/browser": storage.ts (web/memory/custom), device.ts (deviceInfo, fingerprint inputs), + collect.ts (createSignalCollector) +src/server/ "/server": signals.ts (readSignals, bindSignals, checkSignals, decide), ip.ts (clientIp), + headers.ts (securityHeaders, corsPolicy), redact.ts (redact, maskEmail, maskPhone) +scripts/test.ts every part; browser code runs on simulated environments +scripts/check-dist.mjs the built main and /browser bundles import no Node.js modules +``` + +## Rules + +1. **The shared and browser parts use no Node.js APIs** (no `node:*`, no `Buffer`): only Web Crypto, + `TextEncoder`, `btoa`/`atob`. `npm run typecheck` compiles them without `@types/node`, and CI runs + `scripts/check-dist.mjs` on the bundles. +2. **No runtime dependencies.** Frameworks (React, TanStack Query, Fastify…) stay in the consumers. +3. **Signals stay coarse.** No canvas, audio, WebGL, font or IP probing, nothing sent to third parties. + Anything new that is collected goes in the README table "What is collected" in the same PR. +4. **Never store raw ids or tokens.** The server keeps `bindSignals()` output (SHA-256 of ids). +5. **Everything read from a request is untrusted.** `decodeSignals` / `readSignals` never throw, cap + sizes, and return `null` for anything malformed. Keep it that way and add a test for each new field. +6. The signal format is a contract between client and api: `SIGNALS_HEADER`, field names and `v: 1`. + Add optional fields; a breaking change needs `v: 2` and both versions accepted during the rollout. +7. Verdict names (`same`, `changed`, `other-device`, `missing`, `unbound`) are public: add, never rename. +8. Token logic (passwords, sessions, JWT) stays in **auth-kit**; permissions stay in auth-kit. +9. Never commit secrets, `.env` files or real patient data (tests use fictional values). + +## Commands + +```bash +npm run typecheck && npm test && npm run build && node scripts/check-dist.mjs +npm run llms # regenerate llms-full.txt (CI fails when it is stale) +``` + +## Repository rules + +`main` takes pull requests only: squash merge, 1 code-owner review (`@BackStacked`), the `ci` check +required. Rules come from `mgpl-diagnostics/.github` (`scripts/protect-repos.mjs`). + +## Licence + +All rights reserved (see LICENSE). + + +--- + +## File: llms.txt + +# @mgpl-diagnostics/security-kit + +> Security primitives for both sides of MGPL Diagnostics, with zero dependencies. The browser entry collects device signals (device id, tab id, coarse fingerprint, device info) in any frontend and sends them as the x-mgpl-signals header; the server entry validates them, binds them to a session (hashed ids) and checks later requests (same / changed / other-device / missing / unbound). The server entry also has clientIp behind proxies, security and CORS headers, and log redaction. + +Key facts for agents: +- Entries: "." (shared: types, encode/decode, randomId, sha256Hex, constantTimeEqual), "./browser" (createSignalCollector, deviceInfo, storages), "./server" (readSignals, bindSignals, checkSignals, decide, clientIp, 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. +- Token and session logic stays in auth-kit; security-kit only supplies signals and helpers. + +## Docs + +- [README](README.md): entries, what is collected, use, verdicts, server helpers +- [AGENTS.md](AGENTS.md): map and rules +- [llms-full.txt](llms-full.txt): every doc and source file in one document + +## Source + +- [src/shared/signals.ts](src/shared/signals.ts): encode, decode and validate signals +- [src/browser/collect.ts](src/browser/collect.ts): createSignalCollector +- [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/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 + +## Optional + +- [auth-kit](https://github.com/mgpl-diagnostics/auth-kit) +- [client](https://github.com/mgpl-diagnostics/client) +- [api](https://github.com/mgpl-diagnostics/api) + + +--- + +## File: package.json + +```json +{ + "name": "@mgpl-diagnostics/security-kit", + "version": "0.1.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.", + "type": "module", + "license": "SEE LICENSE IN LICENSE", + "repository": "github:mgpl-diagnostics/security-kit", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "AGENTS.md", + "llms.txt", + "LICENSE", + "llms-full.txt" + ], + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.browser.json", + "test": "tsx scripts/test.ts", + "prepare": "tsup", + "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/security-kit README.md AGENTS.md llms.txt package.json src scripts" + }, + "devDependencies": { + "@types/node": "^22.20.5", + "tsup": "^8.5.0", + "tsx": "^4.23.15", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20" + } +} +``` + + +--- + +## File: scripts/check-dist.mjs + +```js +// After `npm run build`: the main and /browser bundles (and every chunk they import) must not load +// Node.js modules, so they run in browsers, workers and React Native. /server may. +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const seen = new Set(); +const bad = []; +function walk(file) { + if (seen.has(file)) return; + seen.add(file); + const src = readFileSync(file, "utf8"); + for (const m of src.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+)["']/g)) { + const spec = m[1]; + if (spec.startsWith("node:") || /^(crypto|net|fs|path|buffer|os)$/.test(spec)) bad.push(`${file} imports ${spec}`); + else if (spec.startsWith(".")) walk(join(dirname(file), spec)); + } + if (/\bBuffer\./.test(src)) bad.push(`${file} uses Buffer`); +} +walk("dist/index.js"); +walk("dist/browser.js"); +if (bad.length) { + console.error(bad.join("\n")); + process.exit(1); +} +console.log(`dist: ${seen.size} browser-safe files`); +``` + + +--- + +## File: scripts/llms-full.mjs + +````js +// Builds llms-full.txt: every doc and source file of the repo in one plain-text document for AI tools. +// node scripts/llms-full.mjs "" <file-or-folder> [file-or-folder …] +// Folders are walked recursively. Output order: README, AGENTS, docs, then the rest alphabetically. +// Skipped: build output, dependencies, lockfiles, generated files, binaries. Files over 150 KB are +// listed (path + size) instead of inlined. CI checks the file is up to date (`npm run llms` + git diff). +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { extname, join, relative } from "node:path"; + +const [title, ...inputs] = process.argv.slice(2); +if (!title || !inputs.length) { + console.error('usage: node scripts/llms-full.mjs "<title>" <file-or-folder> …'); + process.exit(1); +} + +const TEXT = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".md", ".mdx", ".json", ".css", ".yml", ".yaml", ".txt", ".graphql", ".gql", ".sql", ".html"]); +const SKIP_DIRS = new Set(["node_modules", "dist", ".next", ".git", ".turbo", "coverage", "examples", ".pg-test"]); +const SKIP_FILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|llms-full\.txt|[^/]*\.generated\.[^/]+|[^/]*\.log)$/; +const MAX = 150 * 1024; +const LANG = { ".ts": "ts", ".tsx": "tsx", ".mts": "ts", ".cts": "ts", ".js": "js", ".mjs": "js", ".cjs": "js", ".json": "json", ".css": "css", ".yml": "yaml", ".yaml": "yaml", ".graphql": "graphql", ".gql": "graphql", ".sql": "sql", ".html": "html" }; + +const posix = (p) => p.split("\\").join("/"); +const files = new Set(); +function walk(p) { + const st = statSync(p); + if (st.isDirectory()) { + for (const name of readdirSync(p)) if (!SKIP_DIRS.has(name)) walk(join(p, name)); + } else if (TEXT.has(extname(p).toLowerCase()) && !SKIP_FILES.test(posix(p))) { + files.add(posix(relative(".", p))); + } +} +for (const input of inputs) walk(input); + +const rank = (f) => (/^README\.md$/i.test(f) ? 0 : /^AGENTS\.md$/i.test(f) ? 1 : /^docs\//.test(f) ? 2 : 3); +const ordered = [...files].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0)); + +const parts = [`# ${title} — full documentation and source\n\n> Generated by scripts/llms-full.mjs. Do not edit by hand. ${ordered.length} files.\n\n## Contents\n\n${ordered.map((f) => `- ${f}`).join("\n")}\n`]; +const omitted = []; +for (const f of ordered) { + // LF-normalised text, so Windows and Linux checkouts produce byte-identical output + const body = readFileSync(f, "utf8").replace(/\r\n/g, "\n").trim(); + const size = Buffer.byteLength(body); + if (size > MAX) { + omitted.push(`${f} (${(size / 1024).toFixed(0)} KB)`); + parts.push(`\n\n---\n\n## File: ${f}\n\n_Omitted: ${(size / 1024).toFixed(0)} KB. Read the file directly._\n`); + continue; + } + const lang = LANG[extname(f).toLowerCase()]; + const fence = body.includes("```") ? "````" : "```"; + parts.push(`\n\n---\n\n## File: ${f}\n\n${lang ? `${fence}${lang}\n${body}\n${fence}` : body}\n`); +} +const out = parts.join(""); +writeFileSync("llms-full.txt", out); +console.log(`llms-full.txt: ${ordered.length} files, ${(out.length / 1024).toFixed(0)} KB${omitted.length ? `; omitted (too large): ${omitted.join(", ")}` : ""}`); +```` + + +--- + +## File: scripts/test.ts + +```ts +// npm test: every part of security-kit, run on Node.js. The browser part runs against simulated +// environments and storages (it only needs Web Crypto, which Node.js >= 20 has). +import { + canonicalJson, + constantTimeEqual, + decodeSignals, + encodeSignals, + fromBase64Url, + ID_PATTERN, + parseSignals, + randomId, + sha256Hex, + SIGNALS_HEADER, + toBase64Url, + type DeviceSignals, +} from "../src/index"; +import { browserOf, createSignalCollector, deviceInfo, memoryStorage, webStorage, type SignalEnvironment, type SignalStorage } from "../src/browser/index"; +import { + bindSignals, + checkSignals, + clientIp, + corsPolicy, + decide, + maskEmail, + maskPhone, + normalizeIp, + readSignals, + redact, + securityHeaders, +} from "../src/server/index"; + +let passed = 0; +const failures: string[] = []; +async function check(name: string, fn: () => unknown | Promise<unknown>) { + try { + const r = await fn(); + if (r === false) throw new Error("returned false"); + passed++; + } catch (e) { + failures.push(`${name}: ${(e as Error).message}`); + } +} +function eq(a: unknown, b: unknown) { + const x = JSON.stringify(a), y = JSON.stringify(b); + if (x !== y) throw new Error(`expected ${y}, got ${x}`); +} +function ok(v: unknown, msg = "expected truthy") { + if (!v) throw new Error(msg); +} + +// ---------- shared ---------- + +await check("base64url round-trips bytes and UTF-8", () => { + eq(toBase64Url("hello"), "aGVsbG8"); + const s = "नमस्ते ✓ <>"; + eq(new TextDecoder().decode(fromBase64Url(toBase64Url(s))!), s); + const bytes = new Uint8Array([0, 255, 62, 63, 251]); + eq([...fromBase64Url(toBase64Url(bytes))!], [...bytes]); + ok(!toBase64Url(bytes).includes("+") && !toBase64Url(bytes).includes("/") && !toBase64Url(bytes).includes("=")); +}); +await check("fromBase64Url rejects invalid input", () => { + eq(fromBase64Url("a+b/"), null); + eq(fromBase64Url("abcde"), null); + eq(fromBase64Url("ab c"), null); +}); +await check("randomId: 22 base64url chars, unique, fits ID_PATTERN", () => { + const ids = new Set(Array.from({ length: 500 }, () => randomId())); + eq(ids.size, 500); + for (const id of ids) ok(id.length === 22 && ID_PATTERN.test(id), id); +}); +await check("sha256Hex matches the FIPS 180-2 vector", async () => { + eq(await sha256Hex("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); +}); +await check("constantTimeEqual", () => { + ok(constantTimeEqual("abc", "abc")); + ok(!constantTimeEqual("abc", "abd")); + ok(!constantTimeEqual("abc", "abcd")); +}); +await check("canonicalJson sorts keys at every level and drops undefined", () => { + eq(canonicalJson({ b: 1, a: { d: [2, { z: 1, y: 2 }], c: undefined } }), '{"a":{"d":[2,{"y":2,"z":1}]},"b":1}'); +}); + +// ---------- browser: device info ---------- + +const UA = { + chromeWin: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + edgeWin: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.70", + safariIphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", + safariMac: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", + chromeIos: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + samsung: "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + firefoxLinux: "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", + opera: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 OPR/115.0.0.0", + chromebook: "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + androidTablet: "Mozilla/5.0 (Linux; Android 13; SM-X700) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", +}; + +const env = (ua: string, extra: Partial<SignalEnvironment["navigator"]> = {}, screen = { width: 1920, height: 1080, colorDepth: 24 }): SignalEnvironment => ({ + navigator: { userAgent: ua, language: "en-IN", platform: "Win32", hardwareConcurrency: 8, deviceMemory: 8, maxTouchPoints: 0, ...extra }, + screen, + timezone: "Asia/Kolkata", +}); + +await check("browser and version from user agents", () => { + eq(browserOf(UA.chromeWin), { browser: "Chrome", browserVersion: "131" }); + eq(browserOf(UA.edgeWin), { browser: "Edge", browserVersion: "131" }); + eq(browserOf(UA.safariIphone), { browser: "Safari", browserVersion: "17" }); + eq(browserOf(UA.safariMac), { browser: "Safari", browserVersion: "17" }); + eq(browserOf(UA.chromeIos), { browser: "Chrome", browserVersion: "131" }); + eq(browserOf(UA.samsung), { browser: "Samsung Internet", browserVersion: "26" }); + eq(browserOf(UA.firefoxLinux), { browser: "Firefox", browserVersion: "133" }); + eq(browserOf(UA.opera), { browser: "Opera", browserVersion: "115" }); + eq(browserOf("curl/8.0"), { browser: "Other", browserVersion: "" }); +}); +await check("OS and form factor", () => { + eq([deviceInfo(env(UA.chromeWin)).os, deviceInfo(env(UA.chromeWin)).mobile], ["Windows", false]); + eq([deviceInfo(env(UA.safariIphone)).os, deviceInfo(env(UA.safariIphone)).mobile], ["iOS", true]); + eq(deviceInfo(env(UA.safariMac)).os, "macOS"); + eq(deviceInfo(env(UA.safariMac, { maxTouchPoints: 5 })).os, "iOS"); // iPadOS reports a Mac UA + eq([deviceInfo(env(UA.samsung)).os, deviceInfo(env(UA.samsung)).mobile], ["Android", true]); + eq([deviceInfo(env(UA.androidTablet)).os, deviceInfo(env(UA.androidTablet)).mobile], ["Android", false]); + eq(deviceInfo(env(UA.firefoxLinux)).os, "Linux"); + eq(deviceInfo(env(UA.chromebook)).os, "ChromeOS"); + eq(deviceInfo(env(UA.chromeWin, { userAgentData: { mobile: true } })).mobile, true); +}); +await check("device info: language, time zone, screen (rotation-proof), empty environment", () => { + const d = deviceInfo(env(UA.chromeWin)); + eq([d.language, d.timezone, d.screen], ["en-IN", "Asia/Kolkata", "1920x1080"]); + eq(deviceInfo(env(UA.safariIphone, {}, { width: 390, height: 844, colorDepth: 24 })).screen, "844x390"); + eq(deviceInfo(env(UA.safariIphone, {}, { width: 844, height: 390, colorDepth: 24 })).screen, "844x390"); + eq(deviceInfo({}), { os: "Other", browser: "Other", browserVersion: "", mobile: false, language: "", timezone: "", screen: "" }); +}); + +// ---------- browser: collector ---------- + +function collector(e: SignalEnvironment, storage = memoryStorage(), tabStorage = memoryStorage(), app?: string) { + return createSignalCollector({ storage, tabStorage, environment: () => e, app }); +} + +await check("collector: valid signals; ids persist across collectors sharing storage", async () => { + const storage = memoryStorage(); + const tab = memoryStorage(); + const a = await collector(env(UA.chromeWin), storage, tab).collect(); + const b = await collector(env(UA.chromeWin), storage, tab).collect(); + ok(parseSignals(a), "parseSignals rejected collected signals"); + eq(a, b); + const otherTab = await collector(env(UA.chromeWin), storage, memoryStorage()).collect(); + eq(otherTab.deviceId, a.deviceId); + ok(otherTab.tabId !== a.tabId, "new tab gets a new tab id"); + ok(/^[0-9a-f]{32}$/.test(a.fingerprint)); +}); +await check("collector: fingerprint ignores browser version, changes with setup", async () => { + const base = await collector(env(UA.chromeWin)).collect(); + const updated = await collector(env(UA.chromeWin.replace("Chrome/131", "Chrome/132"))).collect(); + eq(updated.fingerprint, base.fingerprint); + const newScreen = await collector(env(UA.chromeWin, {}, { width: 2560, height: 1440, colorDepth: 24 })).collect(); + ok(newScreen.fingerprint !== base.fingerprint); + const otherBrowser = await collector(env(UA.firefoxLinux)).collect(); + ok(otherBrowser.fingerprint !== base.fingerprint); +}); +await check("collector: caches, refresh re-reads, reset forgets ids", async () => { + let lang = "en-IN"; + const storage = memoryStorage(), tab = memoryStorage(); + const c = createSignalCollector({ storage, tabStorage: tab, environment: () => env(UA.chromeWin, { language: lang }) }); + const first = await c.collect(); + lang = "hi-IN"; + eq((await c.collect()).device.language, "en-IN"); + const r = await c.refresh(); + eq(r.device.language, "hi-IN"); + eq(r.deviceId, first.deviceId); + await c.reset(); + const after = await c.collect(); + ok(after.deviceId !== first.deviceId && after.tabId !== first.tabId); +}); +await check("collector: headers round-trip through decodeSignals; app is sent", async () => { + const c = collector(env(UA.safariIphone), memoryStorage(), memoryStorage(), "lab-panel@1.4.0"); + const h = await c.headers(); + eq(Object.keys(h), [SIGNALS_HEADER]); + eq(decodeSignals(h[SIGNALS_HEADER]), await c.collect()); + eq((await c.collect()).app, "lab-panel@1.4.0"); + ok((await c.headerValue()).length < 1024, "header stays small"); +}); +await check("collector: replaces a tampered stored id", async () => { + const storage = memoryStorage(); + await storage.set("mgpl.deviceId", "<script>"); + const s = await collector(env(UA.chromeWin), storage).collect(); + ok(ID_PATTERN.test(s.deviceId)); + eq(await storage.get("mgpl.deviceId"), s.deviceId); +}); +await check("collector: async storage (React Native style)", async () => { + const map = new Map<string, string>(); + const asyncStore: SignalStorage = { get: async (k) => map.get(k) ?? null, set: async (k, v) => void map.set(k, v) }; + const a = await createSignalCollector({ storage: asyncStore, tabStorage: memoryStorage(), environment: () => env(UA.samsung) }).collect(); + const b = await createSignalCollector({ storage: asyncStore, tabStorage: memoryStorage(), environment: () => env(UA.samsung) }).collect(); + eq(a.deviceId, b.deviceId); +}); +await check("webStorage: works without storage and when storage throws", async () => { + const g = globalThis as Record<string, unknown>; + const s = webStorage("local"); + s.set("k", "v"); // no localStorage in Node: memory + eq(s.get("k"), "v"); + Object.defineProperty(g, "localStorage", { configurable: true, get() { throw new Error("SecurityError"); } }); + try { + const t = webStorage("local"); + t.set("k", "w"); + eq(t.get("k"), "w"); + const c = await createSignalCollector({ environment: () => env(UA.chromeWin) }).collect(); + ok(parseSignals(c)); + } finally { + delete g.localStorage; + } +}); +await check("webStorage: uses a real Storage when present", () => { + const g = globalThis as Record<string, unknown>; + const data = new Map<string, string>(); + g.sessionStorage = { getItem: (k: string) => data.get(k) ?? null, setItem: (k: string, v: string) => void data.set(k, v), removeItem: (k: string) => void data.delete(k) }; + try { + const s = webStorage("session"); + s.set("a", "1"); + eq(data.get("a"), "1"); + s.remove?.("a"); + eq(data.has("a"), false); + } finally { + delete g.sessionStorage; + } +}); + +// ---------- shared: decoding untrusted input ---------- + +const good: DeviceSignals = { + v: 1, + deviceId: "AAAAAAAAAAAAAAAAAAAAAA", + tabId: "BBBBBBBBBBBBBBBBBBBBBB", + fingerprint: "0123456789abcdef0123456789abcdef", + device: { os: "Windows", browser: "Chrome", browserVersion: "131", mobile: false, language: "en-IN", timezone: "Asia/Kolkata", screen: "1920x1080" }, +}; +const enc = (v: unknown) => toBase64Url(JSON.stringify(v)); + +await check("decodeSignals accepts good signals and drops unknown keys", () => { + eq(decodeSignals(encodeSignals(good)), good); + eq(decodeSignals(enc({ ...good, extra: "x", device: { ...good.device, gpu: "y" } })), good); +}); +await check("decodeSignals rejects malformed, oversized and hostile values", () => { + const bad: unknown[] = [ + { ...good, v: 2 }, + { ...good, deviceId: "short" }, + { ...good, deviceId: "A".repeat(65) }, + { ...good, tabId: "../../etc/passwd!!!!!!!" }, + { ...good, fingerprint: "XYZ" }, + { ...good, fingerprint: "0123456789ABCDEF0123456789ABCDEF" }, + { ...good, device: { ...good.device, os: "<img src=x>" } }, + { ...good, device: { ...good.device, browser: "a\u0000b" } }, + { ...good, device: { ...good.device, language: "x".repeat(65) } }, + { ...good, device: { ...good.device, mobile: "no" } }, + { ...good, device: null }, + { ...good, app: 42 }, + [good], + "string", + ]; + for (const b of bad) eq(decodeSignals(enc(b)), null); + eq(decodeSignals(undefined), null); + eq(decodeSignals(""), null); + eq(decodeSignals("not base64!"), null); + eq(decodeSignals(toBase64Url("{not json")), null); + eq(decodeSignals("A".repeat(2049)), null); + const polluted = decodeSignals(toBase64Url(JSON.stringify(good).replace("{", '{"__proto__":{"polluted":true},'))); + eq(polluted, good); + eq(({} as Record<string, unknown>).polluted, undefined); +}); + +// ---------- server: reading and checking signals ---------- + +await check("readSignals from Fetch Headers, Node headers (any case) and arrays", () => { + const value = encodeSignals(good); + eq(readSignals(new Headers({ [SIGNALS_HEADER]: value })), good); + eq(readSignals({ "x-mgpl-signals": value }), good); + eq(readSignals({ "X-MGPL-Signals": value }), good); + eq(readSignals({ "x-mgpl-signals": [value, "junk"] }), good); + eq(readSignals({}), null); + eq(readSignals(undefined), null); +}); +await check("bindSignals stores no raw ids", () => { + const b = bindSignals(good); + const json = JSON.stringify(b); + ok(!json.includes(good.deviceId) && !json.includes(good.tabId), "raw id in binding"); + eq(b.fingerprint, good.fingerprint); +}); +await check("checkSignals verdicts", () => { + const b = bindSignals(good); + eq(checkSignals(b, good), { verdict: "same", changes: [], sameDevice: true }); + eq(checkSignals(b, { ...good, tabId: "CCCCCCCCCCCCCCCCCCCCCC" }).verdict, "same"); // another tab of the same browser + eq(checkSignals(b, { ...good, device: { ...good.device, browserVersion: "132" } }).verdict, "same"); + eq(checkSignals(b, { ...good, fingerprint: "f".repeat(32), device: { ...good.device, screen: "2560x1440" } }), { + verdict: "changed", + changes: ["fingerprint", "screen"], + sameDevice: true, + }); + eq(checkSignals(b, { ...good, deviceId: "ZZZZZZZZZZZZZZZZZZZZZZ" }), { verdict: "other-device", changes: ["deviceId"], sameDevice: false }); + eq(checkSignals(b, null).verdict, "missing"); + eq(checkSignals(null, good).verdict, "unbound"); +}); +await check("decide applies the policy", () => { + const b = bindSignals(good); + eq(decide(checkSignals(b, good)), { allow: true, log: false }); + eq(decide(checkSignals(b, { ...good, fingerprint: "f".repeat(32) })), { allow: true, log: true }); + eq(decide(checkSignals(b, { ...good, deviceId: "ZZZZZZZZZZZZZZZZZZZZZZ" })), { allow: false, log: true }); + eq(decide(checkSignals(b, { ...good, deviceId: "ZZZZZZZZZZZZZZZZZZZZZZ" }), { rejectOtherDevice: false }), { allow: true, log: true }); + eq(decide(checkSignals(b, null)), { allow: true, log: true }); + eq(decide(checkSignals(b, null), { requireSignals: true }), { allow: false, log: true }); + eq(decide(checkSignals(null, null)), { allow: true, log: false }); +}); + +// ---------- server: client IP ---------- + +await check("normalizeIp", () => { + eq(normalizeIp("::ffff:10.0.0.7"), "10.0.0.7"); + eq(normalizeIp("203.0.113.9:51234"), "203.0.113.9"); + eq(normalizeIp("[2001:DB8::1]:443"), "2001:db8::1"); + eq(normalizeIp("2001:db8::1"), "2001:db8::1"); + eq(normalizeIp("not-an-ip"), null); + eq(normalizeIp("1.2.3.4, 5.6.7.8"), null); + eq(normalizeIp(undefined), null); +}); +await check("clientIp ignores X-Forwarded-For unless proxies are trusted", () => { + eq(clientIp({ remoteAddress: "::ffff:10.0.0.2", headers: { "x-forwarded-for": "1.1.1.1" } }), "10.0.0.2"); +}); +await check("clientIp with trusted proxies never takes a spoofed left-hand entry", () => { + // client 203.0.113.9 sends a fake XFF; the load balancer (10.0.0.2) appends the real address + eq(clientIp({ remoteAddress: "10.0.0.2", headers: { "x-forwarded-for": "6.6.6.6, 203.0.113.9" }, trustedHops: 1 }), "203.0.113.9"); + // CDN → load balancer → server + eq(clientIp({ remoteAddress: "10.0.0.2", headers: { "x-forwarded-for": "6.6.6.6, 203.0.113.9, 198.51.100.1" }, trustedHops: 2 }), "203.0.113.9"); + eq(clientIp({ remoteAddress: "10.0.0.2", headers: new Headers({ "x-forwarded-for": "203.0.113.9" }), trustedHops: 1 }), "203.0.113.9"); + eq(clientIp({ remoteAddress: "10.0.0.2", trustedHops: 1 }), "10.0.0.2"); + eq(clientIp({ remoteAddress: "10.0.0.2", headers: { "x-forwarded-for": "garbage" }, trustedHops: 1 }), null); +}); + +// ---------- server: headers ---------- + +await check("securityHeaders defaults and options", () => { + const h = securityHeaders(); + eq(h["strict-transport-security"], "max-age=31536000; includeSubDomains"); + eq(h["x-content-type-options"], "nosniff"); + eq(h["cache-control"], "no-store"); + ok(h["content-security-policy"]!.includes("default-src 'none'")); + const dev = securityHeaders({ hsts: false, csp: false, noStore: false }); + ok(!("strict-transport-security" in dev) && !("content-security-policy" in dev) && !("cache-control" in dev)); +}); +await check("corsPolicy: exact, subdomain patterns, schemes, null origin", () => { + const cors = corsPolicy({ origins: ["https://lab.example.in", "https://*.mgpl.example"] }); + ok(cors.allowed("https://lab.example.in")); + ok(cors.allowed("https://panel.mgpl.example")); + ok(cors.allowed("https://a.b.mgpl.example")); + ok(!cors.allowed("https://mgpl.example"), "bare domain"); + ok(!cors.allowed("http://panel.mgpl.example"), "scheme"); + ok(!cors.allowed("https://evilmgpl.example"), "suffix without dot"); + ok(!cors.allowed("https://mgpl.example.evil.com"), "domain as prefix"); + ok(!cors.allowed("https://lab.example.in.evil.com")); + ok(!cors.allowed("null")); + ok(!cors.allowed(undefined)); + eq(cors.headers("https://evil.com"), { vary: "Origin" }); + const pre = cors.headers("https://lab.example.in", true); + eq(pre["access-control-allow-origin"], "https://lab.example.in"); + ok(pre["access-control-allow-headers"]!.includes(SIGNALS_HEADER)); + ok(!("access-control-allow-credentials" in pre)); +}); +await check("corsPolicy refuses credentials with '*'", () => { + let threw = false; + try { + corsPolicy({ origins: ["*"], credentials: true }); + } catch { + threw = true; + } + ok(threw); + eq(corsPolicy({ origins: ["*"] }).headers("https://any.site")["access-control-allow-origin"], "*"); +}); + +// ---------- server: redaction ---------- + +await check("redact hides secrets at any depth and keeps the rest", () => { + const input = { + email: "kavita.sharma@example.com", + password: "hunter2", + nested: { refresh_token: "r", "X-Api-Key": "k", list: [{ newPassword: "p", ok: 1 }], stripeSecret: "s" }, + headers: { authorization: "Bearer abc", cookie: "sid=1", accept: "*/*" }, + note: "Bearer xyz.abc.def", + when: new Date(0), + }; + const out = redact(input); + eq(out.password, "[redacted]"); + eq(out.nested.refresh_token, "[redacted]"); + eq(out.nested["X-Api-Key"], "[redacted]"); + eq(out.nested.stripeSecret, "[redacted]"); + eq(out.nested.list[0], { newPassword: "[redacted]", ok: 1 }); + eq(out.headers, { authorization: "[redacted]", cookie: "[redacted]", accept: "*/*" }); + eq(out.note, "Bearer [redacted]"); + eq(out.email, "kavita.sharma@example.com"); + ok(out.when instanceof Date); + eq(input.password, "hunter2"); // the input is untouched +}); +await check("redact: cycles, Headers, Map, errors, extra keys, depth", () => { + const a: Record<string, unknown> = { name: "x" }; + a.self = a; + eq(redact(a), { name: "x", self: "[circular]" }); + eq(redact(new Headers({ authorization: "Bearer t", "content-type": "application/json" })), { authorization: "[redacted]", "content-type": "application/json" }); + eq(redact(new Map([["token", "t"], ["id", "1"]])), { token: "[redacted]", id: "1" }); + eq(redact({ uhid: "MGPL-1", phone: "9876543210" }, { keys: ["phone"] }), { uhid: "MGPL-1", phone: "[redacted]" }); + eq(redact(new Error("Bearer abc failed")), { name: "Error", message: "Bearer [redacted]" }); + eq(redact({ a: { b: { c: 1 } } }, { maxDepth: 2 }), { a: { b: "[depth]" } }); +}); +await check("maskEmail and maskPhone", () => { + eq(maskEmail("kavita.sharma@example.com"), "k***a@example.com"); + eq(maskEmail("ab@example.com"), "a*@example.com"); + eq(maskEmail("nope"), ""); + eq(maskPhone("+91 98765 43210"), "******3210"); + eq(maskPhone("98765 43210"), "******3210"); + eq(maskPhone(""), ""); +}); + +console.log(`${passed} passed, ${failures.length} failed`); +if (failures.length) { + for (const f of failures) console.error(` ✗ ${f}`); + process.exit(1); +} +``` + + +--- + +## File: src/browser/collect.ts + +```ts +import { ID_PATTERN } from "../shared/constants"; +import { canonicalJson, randomId, sha256Hex } from "../shared/crypto"; +import { encodeSignals, signalHeaders } from "../shared/signals"; +import type { DeviceSignals } from "../shared/types"; +import { defaultEnvironment, deviceInfo, fingerprintComponents, type SignalEnvironment } from "./device"; +import { webStorage, type SignalStorage } from "./storage"; + +export interface SignalCollectorOptions { + /** Keeps the device id. Default: localStorage (memory when unavailable). */ + storage?: SignalStorage; + /** Keeps the tab id. Default: sessionStorage (memory when unavailable). */ + tabStorage?: SignalStorage; + /** Storage key prefix. Default "mgpl.". */ + prefix?: string; + /** The app's version, sent as `app`. */ + app?: string; + /** Where device facts come from. Default: globalThis (navigator, screen, Intl). */ + environment?: () => SignalEnvironment; +} + +export interface SignalCollector { + /** The signals; ids are created on first use and then reused. Cached until `refresh()`. */ + collect(): Promise<DeviceSignals>; + /** `{ "x-mgpl-signals": "…" }` for a fetch / GraphQL request. */ + headers(): Promise<Record<string, string>>; + /** The encoded header value alone. */ + headerValue(): Promise<string>; + /** Re-reads the device facts (after a resize or a language change, for example). */ + refresh(): Promise<DeviceSignals>; + /** Forgets the device and tab ids (for "forget this device" or after a sign-out on a shared PC). */ + reset(): Promise<void>; +} + +/** + * Collects the security signals the backend checks at login and refresh. Framework-agnostic: call + * `headers()` from any fetch wrapper, GraphQL client or query library. + * + * const signals = createSignalCollector({ app: "lab-panel@1.4.0" }); + * fetch(url, { headers: { ...(await signals.headers()), authorization } }); + */ +export function createSignalCollector(o: SignalCollectorOptions = {}): SignalCollector { + const storage = o.storage ?? webStorage("local"); + const tabStorage = o.tabStorage ?? webStorage("session"); + const prefix = o.prefix ?? "mgpl."; + const env = o.environment ?? defaultEnvironment; + let cached: Promise<DeviceSignals> | null = null; + + async function idFrom(store: SignalStorage, key: string): Promise<string> { + const existing = await store.get(prefix + key); + if (typeof existing === "string" && ID_PATTERN.test(existing)) return existing; + const id = randomId(); + await store.set(prefix + key, id); + return id; + } + + async function build(): Promise<DeviceSignals> { + const e = env(); + const [deviceId, tabId, hash] = await Promise.all([ + idFrom(storage, "deviceId"), + idFrom(tabStorage, "tabId"), + sha256Hex(canonicalJson(fingerprintComponents(e))), + ]); + return { v: 1, deviceId, tabId, fingerprint: hash.slice(0, 32), device: deviceInfo(e), ...(o.app ? { app: o.app.slice(0, 64) } : {}) }; + } + + const collect = () => { + cached ??= build().catch((err) => { + cached = null; // retry next time + throw err; + }); + return cached; + }; + + return { + collect, + headers: async () => signalHeaders(await collect()), + headerValue: async () => encodeSignals(await collect()), + refresh() { + cached = null; + return collect(); + }, + async reset() { + cached = null; + await storage.remove?.(prefix + "deviceId"); + await tabStorage.remove?.(prefix + "tabId"); + }, + }; +} +``` + + +--- + +## File: src/browser/device.ts + +```ts +import type { DeviceInfo } from "../shared/types"; + +/** + * The parts of the browser environment the collector reads. Defaults to `globalThis`; tests and + * non-browser apps (React Native, Electron main) pass their own. + */ +export interface SignalEnvironment { + navigator?: { + userAgent?: string; + language?: string; + platform?: string; + hardwareConcurrency?: number; + deviceMemory?: number; + maxTouchPoints?: number; + userAgentData?: { mobile?: boolean; platform?: string }; + }; + screen?: { width?: number; height?: number; colorDepth?: number }; + /** IANA time zone; defaults to Intl. */ + timezone?: string; +} + +export function defaultEnvironment(): SignalEnvironment { + const g = globalThis as unknown as { navigator?: SignalEnvironment["navigator"]; screen?: SignalEnvironment["screen"] }; + let timezone = ""; + try { + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""; + } catch { + /* no Intl time zones */ + } + return { navigator: g.navigator, screen: g.screen, timezone }; +} + +const cut = (s: string) => s.slice(0, 64); + +function osOf(ua: string, touchPoints: number, hint?: string): string { + if (/Windows/i.test(ua) || hint === "Windows") return "Windows"; + if (/Android/i.test(ua) || hint === "Android") return "Android"; + if (/iPhone|iPad|iPod/i.test(ua) || hint === "iOS") return "iOS"; + if (/CrOS/i.test(ua) || hint === "Chrome OS" || hint === "ChromeOS") return "ChromeOS"; + // iPadOS 13+ reports itself as a Mac; a Mac has no touch screen + if (/Macintosh|Mac OS X/i.test(ua) || hint === "macOS") return touchPoints > 1 ? "iOS" : "macOS"; + if (/Linux/i.test(ua) || hint === "Linux") return "Linux"; + return "Other"; +} + +const BROWSERS: [RegExp, string][] = [ + [/Edg(?:e|A|iOS)?\/(\d+)/, "Edge"], + [/(?:OPR|Opera)\/(\d+)/, "Opera"], + [/SamsungBrowser\/(\d+)/, "Samsung Internet"], + [/(?:Firefox|FxiOS)\/(\d+)/, "Firefox"], + [/(?:Chrome|CriOS)\/(\d+)/, "Chrome"], + [/Version\/(\d+)[^ ]* (?:Mobile\/\S+ )?Safari\//, "Safari"], +]; + +/** Browser family and major version from a user-agent string. */ +export function browserOf(ua: string): { browser: string; browserVersion: string } { + for (const [re, name] of BROWSERS) { + const m = re.exec(ua); + if (m) return { browser: name, browserVersion: m[1] ?? "" }; + } + return { browser: "Other", browserVersion: "" }; +} + +/** Coarse `DeviceInfo` from the environment. Never throws; unknown values are "". */ +export function deviceInfo(env: SignalEnvironment = defaultEnvironment()): DeviceInfo { + const nav = env.navigator ?? {}; + const ua = typeof nav.userAgent === "string" ? nav.userAgent : ""; + const touch = typeof nav.maxTouchPoints === "number" ? nav.maxTouchPoints : 0; + const os = osOf(ua, touch, nav.userAgentData?.platform); + const { browser, browserVersion } = browserOf(ua); + const mobile = typeof nav.userAgentData?.mobile === "boolean" ? nav.userAgentData.mobile : /Mobi|iPhone|iPod|Android.*Mobile/i.test(ua); + const w = Math.round(env.screen?.width ?? 0), h = Math.round(env.screen?.height ?? 0); + return { + os, + browser, + browserVersion: cut(browserVersion), + mobile, + language: cut(typeof nav.language === "string" ? nav.language : ""), + timezone: cut(env.timezone ?? ""), + screen: w > 0 && h > 0 ? `${Math.max(w, h)}x${Math.min(w, h)}` : "", + }; +} + +/** + * The attributes the fingerprint hashes. Deliberately coarse and stable: no browser version (it changes + * with every update), no canvas, audio, WebGL or font probing. + */ +export function fingerprintComponents(env: SignalEnvironment = defaultEnvironment()): Record<string, string | number | boolean> { + const info = deviceInfo(env); + const nav = env.navigator ?? {}; + const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0); + return { + os: info.os, + browser: info.browser, + mobile: info.mobile, + platform: typeof nav.platform === "string" ? nav.platform.slice(0, 64) : "", + language: info.language, + timezone: info.timezone, + screen: info.screen, + colorDepth: num(env.screen?.colorDepth), + cores: num(nav.hardwareConcurrency), + memory: num(nav.deviceMemory), + touch: num(nav.maxTouchPoints), + }; +} +``` + + +--- + +## File: src/browser/index.ts + +```ts +// "@mgpl-diagnostics/security-kit/browser": collects device signals in any frontend. No Node.js APIs. +export { createSignalCollector, type SignalCollector, type SignalCollectorOptions } from "./collect"; +export { browserOf, defaultEnvironment, deviceInfo, fingerprintComponents, type SignalEnvironment } from "./device"; +export { memoryStorage, webStorage, type SignalStorage } from "./storage"; +export * from "../index"; +``` + + +--- + +## File: src/browser/storage.ts + +```ts +/** Where the collector keeps its ids. Sync or async, so React Native's AsyncStorage fits too. */ +export interface SignalStorage { + get(key: string): string | null | undefined | Promise<string | null | undefined>; + set(key: string, value: string): void | Promise<void>; + remove?(key: string): void | Promise<void>; +} + +/** In-memory storage: ids last as long as the page (or process). */ +export function memoryStorage(): SignalStorage { + const map = new Map<string, string>(); + return { get: (k) => map.get(k), set: (k, v) => void map.set(k, v), remove: (k) => void map.delete(k) }; +} + +/** + * Wraps `localStorage` or `sessionStorage`. Falls back to memory when the storage is missing or throws + * (private mode, blocked site data, server rendering). + */ +export function webStorage(kind: "local" | "session"): SignalStorage { + const fallback = memoryStorage(); + const store = (): Storage | null => { + try { + const s = (globalThis as Record<string, unknown>)[kind === "local" ? "localStorage" : "sessionStorage"] as Storage | undefined; + return s ?? null; + } catch { + return null; + } + }; + return { + get(k) { + try { + const v = store()?.getItem(k); + if (v != null) return v; + } catch { + /* blocked */ + } + return fallback.get(k); + }, + set(k, v) { + fallback.set(k, v); + try { + store()?.setItem(k, v); + } catch { + /* quota or blocked: memory keeps it */ + } + }, + remove(k) { + fallback.remove?.(k); + try { + store()?.removeItem(k); + } catch { + /* blocked */ + } + }, + }; +} +``` + + +--- + +## File: src/index.ts + +```ts +// "@mgpl-diagnostics/security-kit": the shared part. Runs in browsers, Node.js, workers and React Native. +export type { DeviceInfo, DeviceSignals } from "./shared/types"; +export { FINGERPRINT_PATTERN, ID_PATTERN, SIGNAL_LIMITS, SIGNALS_HEADER } from "./shared/constants"; +export { canonicalJson, constantTimeEqual, fromBase64Url, randomBytes, randomId, sha256Hex, toBase64Url } from "./shared/crypto"; +export { decodeSignals, encodeSignals, parseDeviceInfo, parseSignals, signalHeaders } from "./shared/signals"; +``` + + +--- + +## File: src/server/headers.ts + +```ts +import { SIGNALS_HEADER } from "../shared/constants"; + +export interface SecurityHeaderOptions { + /** HSTS max-age in seconds; false to omit (plain-HTTP development). Default one year. */ + hsts?: number | false; + /** + * Content-Security-Policy. Default for a JSON / GraphQL API: nothing may load and nothing may frame it. + * Pass a string for pages (GraphiQL needs its own), or false to omit. + */ + csp?: string | false; + /** Cache-Control: no-store. Default true (responses carry personal data and tokens). */ + noStore?: boolean; +} + +export const API_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"; + +/** Response headers for the API. Set them on every response (a Fastify onSend hook, for example). */ +export function securityHeaders(o: SecurityHeaderOptions = {}): Record<string, string> { + const h: Record<string, string> = { + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + "referrer-policy": "no-referrer", + "cross-origin-opener-policy": "same-origin", + "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + "x-dns-prefetch-control": "off", + }; + const hsts = o.hsts ?? 31_536_000; + if (hsts !== false) h["strict-transport-security"] = `max-age=${Math.max(0, Math.floor(hsts))}; includeSubDomains`; + const csp = o.csp ?? API_CSP; + if (csp !== false) h["content-security-policy"] = csp; + if (o.noStore ?? true) h["cache-control"] = "no-store"; + return h; +} + +export interface CorsOptions { + /** + * Allowed origins: exact ("https://lab.example.in"), a subdomain pattern ("https://*.example.in", which + * does not match the bare domain), or "*" (any origin, and then never credentials). + */ + origins: readonly string[]; + /** Extra request headers to allow (authorization, content-type and x-mgpl-signals are always allowed). */ + allowHeaders?: readonly string[]; + methods?: readonly string[]; + /** Access-Control-Allow-Credentials (cookies). Default false: the API uses bearer tokens. */ + credentials?: boolean; + /** Preflight cache, seconds. Default 600. */ + maxAge?: number; +} + +function matches(pattern: string, origin: string): boolean { + if (pattern === origin) return true; + const m = /^(https?):\/\/\*\.(.+)$/.exec(pattern); + if (!m) return false; + let url: URL; + try { + url = new URL(origin); + } catch { + return false; + } + if (url.origin !== origin || `${m[1]}:` !== url.protocol) return false; + const host = url.port ? `${url.hostname}:${url.port}` : url.hostname; + return host.endsWith(`.${m[2]}`) && host.length > m[2]!.length + 1; +} + +/** A CORS policy: `allowed(origin)` and the response `headers(origin)` (empty when not allowed). */ +export function corsPolicy(o: CorsOptions) { + const any = o.origins.includes("*"); + if (any && o.credentials) throw new Error("security-kit: CORS cannot allow credentials for any origin ('*')"); + const allowHeaders = [...new Set(["authorization", "content-type", SIGNALS_HEADER, ...(o.allowHeaders ?? []).map((h) => h.toLowerCase())])]; + const methods = o.methods ?? ["GET", "POST", "OPTIONS"]; + + const allowed = (origin: string | null | undefined): origin is string => + typeof origin === "string" && origin !== "null" && (any || o.origins.some((p) => matches(p, origin))); + + return { + allowed, + /** Headers for the response; `preflight` adds the Allow-Methods / Allow-Headers / Max-Age set. */ + headers(origin: string | null | undefined, preflight = false): Record<string, string> { + if (!allowed(origin)) return { vary: "Origin" }; + const h: Record<string, string> = { "access-control-allow-origin": any ? "*" : origin, vary: "Origin" }; + if (o.credentials) h["access-control-allow-credentials"] = "true"; + if (preflight) { + h["access-control-allow-methods"] = methods.join(", "); + h["access-control-allow-headers"] = allowHeaders.join(", "); + h["access-control-max-age"] = String(o.maxAge ?? 600); + } + return h; + }, + }; +} +``` + + +--- + +## File: src/server/index.ts + +```ts +// "@mgpl-diagnostics/security-kit/server": the backend part (Node.js >= 20). +export { + bindSignals, + checkSignals, + decide, + hashSignalId, + headerValue, + readSignals, + type HeaderSource, + type SignalBinding, + type SignalCheck, + type SignalPolicy, + type SignalVerdict, +} from "./signals"; +export { clientIp, normalizeIp, type ClientIpOptions } from "./ip"; +export { API_CSP, corsPolicy, securityHeaders, type CorsOptions, type SecurityHeaderOptions } from "./headers"; +export { maskEmail, maskPhone, REDACTED, redact, SENSITIVE_KEYS, type RedactOptions } from "./redact"; +export * from "../index"; +``` + + +--- + +## File: src/server/ip.ts + +```ts +import { isIP } from "node:net"; +import { headerValue, type HeaderSource } from "./signals"; + +/** "::ffff:10.0.0.1" → "10.0.0.1"; strips brackets and a port; null when it is not an IP address. */ +export function normalizeIp(value: string | undefined | null): string | null { + if (!value) return null; + let v = value.trim(); + if (v.startsWith("[")) v = v.slice(1, v.indexOf("]") > 0 ? v.indexOf("]") : undefined); // "[::1]:443" + else if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(v)) v = v.slice(0, v.lastIndexOf(":")); // "1.2.3.4:5678" + if (/^::ffff:\d{1,3}(\.\d{1,3}){3}$/i.test(v)) v = v.slice(7); + return isIP(v) ? v.toLowerCase() : null; +} + +export interface ClientIpOptions { + /** The socket's peer address (Fastify `request.socket.remoteAddress`, Node `req.socket.remoteAddress`). */ + remoteAddress?: string | null; + headers?: HeaderSource; + /** + * How many reverse proxies in front of the server append to X-Forwarded-For (a load balancer = 1, + * CDN + load balancer = 2). Default 0: the header is ignored, because any client can send it. + */ + trustedHops?: number; +} + +/** + * The client's IP address. With `trustedHops` = n, walks X-Forwarded-For from the right and takes the + * address the outermost trusted proxy saw, so a spoofed left-hand entry is never used. + */ +export function clientIp(o: ClientIpOptions): string | null { + const peer = normalizeIp(o.remoteAddress); + const hops = Math.max(0, Math.floor(o.trustedHops ?? 0)); + if (hops === 0) return peer; + const xff = headerValue(o.headers, "x-forwarded-for"); + const chain = (xff ? xff.split(",").map((s) => s.trim()).filter(Boolean) : []).concat(peer ? [peer] : []); + // chain = [client?, …, proxy1, peer]; the trusted part is the last `hops` entries + const idx = chain.length - 1 - hops; + return idx >= 0 ? normalizeIp(chain[idx]) : normalizeIp(chain[0]); +} +``` + + +--- + +## File: src/server/redact.ts + +```ts +/** Keys (compared without case, "-" or "_") whose values are always hidden. */ +export const SENSITIVE_KEYS = [ + "password", "newpassword", "currentpassword", "oldpassword", "passwordhash", + "token", "accesstoken", "refreshtoken", "idtoken", "resettoken", "sessiontoken", + "authorization", "proxyauthorization", "cookie", "setcookie", + "secret", "clientsecret", "jwtsecret", "apikey", "xapikey", "privatekey", + "otp", "pin", "cvv", "cardnumber", +] as const; + +const SUFFIXES = ["password", "token", "secret", "apikey"]; +export const REDACTED = "[redacted]"; + +const norm = (k: string) => k.toLowerCase().replace(/[-_]/g, ""); + +export interface RedactOptions { + /** More keys to hide (any form: "x-api-key", "myField"). */ + keys?: readonly string[]; + /** Maximum depth; deeper values become "[depth]". Default 8. */ + maxDepth?: number; +} + +/** + * A copy of `value` with secrets replaced by "[redacted]", for logs and error reports. Handles nested + * objects, arrays, Headers-like maps and cycles. Strings that look like bearer tokens are hidden too. + */ +export function redact<T>(value: T, o: RedactOptions = {}): T { + const extra = new Set((o.keys ?? []).map(norm)); + const exact = new Set<string>([...SENSITIVE_KEYS, ...extra]); + const maxDepth = o.maxDepth ?? 8; + const seen = new WeakSet<object>(); + const sensitive = (k: string) => { + const n = norm(k); + return exact.has(n) || SUFFIXES.some((s) => n.endsWith(s)); + }; + + const walk = (v: unknown, depth: number): unknown => { + if (typeof v === "string") return /^Bearer\s+\S+/i.test(v) ? "Bearer [redacted]" : v; + if (v === null || typeof v !== "object") return v; + if (v instanceof Date) return v; + if (seen.has(v)) return "[circular]"; + if (depth >= maxDepth) return "[depth]"; + seen.add(v); + if (Array.isArray(v)) return v.map((x) => walk(x, depth + 1)); + if (v instanceof Map) return Object.fromEntries([...v].map(([k, x]) => [k, sensitive(String(k)) ? REDACTED : walk(x, depth + 1)])); + if (typeof (v as { forEach?: unknown }).forEach === "function" && typeof (v as { get?: unknown }).get === "function") { + // Fetch Headers + const out: Record<string, unknown> = {}; + (v as Headers).forEach((x, k) => (out[k] = sensitive(k) ? REDACTED : walk(x, depth + 1))); + return out; + } + if (v instanceof Error) return { name: v.name, message: walk(v.message, depth + 1) }; + const out: Record<string, unknown> = {}; + for (const [k, x] of Object.entries(v)) out[k] = sensitive(k) ? REDACTED : walk(x, depth + 1); + return out; + }; + return walk(value, 0) as T; +} + +/** "kavita.sharma@example.com" → "k***a@example.com". Returns "" for anything that is not an email. */ +export function maskEmail(email: string): string { + const at = email.lastIndexOf("@"); + if (at < 1) return ""; + const user = email.slice(0, at); + const shown = user.length <= 2 ? `${user[0]}*` : `${user[0]}***${user[user.length - 1]}`; + return `${shown}${email.slice(at)}`; +} + +/** Keeps the last `visible` digits: "+91 98765 43210" → "******3210". */ +export function maskPhone(phone: string, visible = 4): string { + const digits = phone.replace(/\D/g, ""); + if (!digits) return ""; + const keep = Math.min(visible, Math.max(0, digits.length - 4)); + return "*".repeat(digits.length - keep).slice(-6) + digits.slice(digits.length - keep); +} +``` + + +--- + +## File: src/server/signals.ts + +```ts +import { createHash, timingSafeEqual } from "node:crypto"; +import { SIGNALS_HEADER } from "../shared/constants"; +import { decodeSignals } from "../shared/signals"; +import type { DeviceInfo, DeviceSignals } from "../shared/types"; + +/** Request headers as Fetch `Headers`, Node's `IncomingHttpHeaders`, or a plain record. */ +export type HeaderSource = Headers | { get(name: string): string | null } | Record<string, string | string[] | undefined>; + +/** One header value (the first, when repeated); lookup is case-insensitive for records. */ +export function headerValue(headers: HeaderSource | undefined, name: string): string | undefined { + if (!headers) return undefined; + if (typeof (headers as { get?: unknown }).get === "function") return (headers as { get(n: string): string | null }).get(name) ?? undefined; + const rec = headers as Record<string, string | string[] | undefined>; + const lower = name.toLowerCase(); + const key = lower in rec ? lower : Object.keys(rec).find((k) => k.toLowerCase() === lower); + const v = key === undefined ? undefined : rec[key]; + return Array.isArray(v) ? v[0] : v; +} + +/** The request's `DeviceSignals`, or null when the header is missing or invalid. Never throws. */ +export function readSignals(headers: HeaderSource | undefined): DeviceSignals | null { + return decodeSignals(headerValue(headers, SIGNALS_HEADER)); +} + +/** SHA-256 (base64url) of an id, for storage: the raw device id never sits in the database. */ +export function hashSignalId(id: string): string { + return createHash("sha256").update(id, "utf8").digest("base64url"); +} + +/** + * What the server keeps on a session (and its refresh token) to recognise the device later. + * Store it as it is; it holds no raw id. + */ +export interface SignalBinding { + deviceIdHash: string; + fingerprint: string; + device: DeviceInfo; + /** Tab id at login, hashed; informational (a refresh may come from another tab of the same browser). */ + tabIdHash: string; +} + +export function bindSignals(signals: DeviceSignals): SignalBinding { + return { + deviceIdHash: hashSignalId(signals.deviceId), + fingerprint: signals.fingerprint, + device: { ...signals.device }, + tabIdHash: hashSignalId(signals.tabId), + }; +} + +/** + * - `same`: same device id and fingerprint. + * - `changed`: same device id, but the fingerprint, OS, browser or form factor changed (log it; a browser + * update, new screen or language change does this). + * - `other-device`: a different device id (reject: the token was copied to another browser). + * - `missing`: the request has no valid signals but the session has a binding. + * - `unbound`: the session has no binding (created before signals existed, or by a non-browser client). + */ +export type SignalVerdict = "same" | "changed" | "other-device" | "missing" | "unbound"; + +export interface SignalCheck { + verdict: SignalVerdict; + /** What differs: "deviceId", "fingerprint", "os", "browser", "mobile", "timezone", "language", "screen". */ + changes: string[]; + /** True for "same" and "changed"; false otherwise. A caller decides what to do with "missing"/"unbound". */ + sameDevice: boolean; +} + +const eq = (a: string, b: string) => { + const x = Buffer.from(a), y = Buffer.from(b); + return x.length === y.length && timingSafeEqual(x, y); +}; + +/** Compares a stored binding with a request's signals. Pure; never throws. */ +export function checkSignals(binding: SignalBinding | null | undefined, incoming: DeviceSignals | null | undefined): SignalCheck { + if (!binding) return { verdict: "unbound", changes: [], sameDevice: false }; + if (!incoming) return { verdict: "missing", changes: [], sameDevice: false }; + if (!eq(binding.deviceIdHash, hashSignalId(incoming.deviceId))) return { verdict: "other-device", changes: ["deviceId"], sameDevice: false }; + const changes: string[] = []; + if (binding.fingerprint !== incoming.fingerprint) changes.push("fingerprint"); + for (const k of ["os", "browser", "mobile", "timezone", "language", "screen"] as const) { + if (binding.device?.[k] !== incoming.device[k]) changes.push(k); + } + return { verdict: changes.length ? "changed" : "same", changes, sameDevice: true }; +} + +export interface SignalPolicy { + /** Reject a session used with another device id. Default true. */ + rejectOtherDevice?: boolean; + /** Reject when signals are missing but the session is bound. Default false (old clients keep working). */ + requireSignals?: boolean; +} + +/** + * Applies a policy to a check: `allow` false means end the session; `log` true means write an audit + * entry (every verdict except "same" and "unbound"). + */ +export function decide(check: SignalCheck, policy: SignalPolicy = {}): { allow: boolean; log: boolean } { + const rejectOther = policy.rejectOtherDevice ?? true; + switch (check.verdict) { + case "same": + case "unbound": + return { allow: true, log: false }; + case "changed": + return { allow: true, log: true }; + case "other-device": + return { allow: !rejectOther, log: true }; + case "missing": + return { allow: !policy.requireSignals, log: true }; + } +} +``` + + +--- + +## File: src/shared/constants.ts + +```ts +/** HTTP header that carries the encoded `DeviceSignals` (see `encodeSignals`). */ +export const SIGNALS_HEADER = "x-mgpl-signals"; + +/** Limits enforced by `decodeSignals`; anything larger or malformed is rejected. */ +export const SIGNAL_LIMITS = { + /** Encoded header value, in characters. */ + header: 2048, + /** Any single string inside DeviceInfo, and `app`. */ + field: 64, +} as const; + +/** Ids are 16 to 64 base64url characters (a UUID with or without dashes also fits). */ +export const ID_PATTERN = /^[A-Za-z0-9_-]{16,64}$/; +/** Fingerprints are 32 lowercase hex characters. */ +export const FINGERPRINT_PATTERN = /^[0-9a-f]{32}$/; +``` + + +--- + +## File: src/shared/crypto.ts + +```ts +// Primitives that run unchanged in browsers, Node.js >= 20, workers and React Native with Web Crypto: +// only globalThis.crypto, TextEncoder, btoa and atob. No Buffer, no node: imports. + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function webCrypto(): Crypto { + const c = (globalThis as { crypto?: Crypto }).crypto; + if (!c?.getRandomValues) throw new Error("security-kit: Web Crypto (globalThis.crypto) is not available"); + return c; +} + +/** base64url (RFC 4648 §5, no padding) of bytes or of a UTF-8 string. */ +export function toBase64Url(input: Uint8Array | string): string { + const bytes = typeof input === "string" ? enc.encode(input) : input; + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** Bytes of a base64url string; null when it is not valid base64url. */ +export function fromBase64Url(value: string): Uint8Array | null { + if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) return null; + const b64 = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4); + try { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } catch { + return null; + } +} + +export const utf8 = { + encode: (s: string) => enc.encode(s), + decode: (b: Uint8Array) => dec.decode(b), +}; + +/** Cryptographically random bytes. */ +export function randomBytes(n: number): Uint8Array { + const out = new Uint8Array(n); + webCrypto().getRandomValues(out); + return out; +} + +/** A random id: `bytes` random bytes as base64url (16 bytes → 22 characters, 128 bits). */ +export function randomId(bytes = 16): string { + return toBase64Url(randomBytes(bytes)); +} + +/** SHA-256 as lowercase hex (async, Web Crypto). */ +export async function sha256Hex(input: string | Uint8Array): Promise<string> { + const data = typeof input === "string" ? enc.encode(input) : input; + const digest = new Uint8Array(await webCrypto().subtle.digest("SHA-256", data as BufferSource)); + let hex = ""; + for (const b of digest) hex += b.toString(16).padStart(2, "0"); + return hex; +} + +/** + * Compares two strings in time that depends only on their lengths. Use it for hashes and ids + * of a fixed length; a different length returns false at once. + */ +export function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +/** JSON with object keys sorted at every level, so equal values always serialise identically. */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const obj = value as Record<string, unknown>; + const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`; +} +``` + + +--- + +## File: src/shared/signals.ts + +```ts +import { FINGERPRINT_PATTERN, ID_PATTERN, SIGNAL_LIMITS, SIGNALS_HEADER } from "./constants"; +import { fromBase64Url, toBase64Url, utf8 } from "./crypto"; +import type { DeviceInfo, DeviceSignals } from "./types"; + +/** Header value for `DeviceSignals`: base64url of their JSON. */ +export function encodeSignals(signals: DeviceSignals): string { + return toBase64Url(JSON.stringify(signals)); +} + +/** `{ "x-mgpl-signals": encodeSignals(signals) }`, ready to merge into request headers. */ +export function signalHeaders(signals: DeviceSignals): Record<string, string> { + return { [SIGNALS_HEADER]: encodeSignals(signals) }; +} + +// printable ASCII and common Unicode letters; no control characters, no angle brackets +const SAFE_TEXT = /^[^\u0000-\u001f\u007f<>]*$/; +const text = (v: unknown): string | null => (typeof v === "string" && v.length <= SIGNAL_LIMITS.field && SAFE_TEXT.test(v) ? v : null); + +/** Checks an untrusted value and returns clean `DeviceInfo`, or null if any field is wrong. */ +export function parseDeviceInfo(v: unknown): DeviceInfo | null { + if (!v || typeof v !== "object") return null; + const d = v as Record<string, unknown>; + const os = text(d.os), browser = text(d.browser), browserVersion = text(d.browserVersion); + const language = text(d.language), timezone = text(d.timezone), screen = text(d.screen); + if (os === null || browser === null || browserVersion === null || language === null || timezone === null || screen === null) return null; + if (typeof d.mobile !== "boolean") return null; + return { os, browser, browserVersion, mobile: d.mobile, language, timezone, screen }; +} + +/** Checks an untrusted object and returns clean `DeviceSignals` (unknown keys dropped), or null. */ +export function parseSignals(v: unknown): DeviceSignals | null { + if (!v || typeof v !== "object" || Array.isArray(v)) return null; + const s = v as Record<string, unknown>; + if (s.v !== 1) return null; + if (typeof s.deviceId !== "string" || !ID_PATTERN.test(s.deviceId)) return null; + if (typeof s.tabId !== "string" || !ID_PATTERN.test(s.tabId)) return null; + if (typeof s.fingerprint !== "string" || !FINGERPRINT_PATTERN.test(s.fingerprint)) return null; + const device = parseDeviceInfo(s.device); + if (!device) return null; + let app: string | undefined; + if (s.app !== undefined) { + const a = text(s.app); + if (a === null) return null; + app = a; + } + return { v: 1, deviceId: s.deviceId, tabId: s.tabId, fingerprint: s.fingerprint, device, ...(app !== undefined ? { app } : {}) }; +} + +/** Decodes and validates a header value; null when missing, too long or malformed. Never throws. */ +export function decodeSignals(value: string | null | undefined): DeviceSignals | null { + if (typeof value !== "string" || !value || value.length > SIGNAL_LIMITS.header) return null; + const bytes = fromBase64Url(value.trim()); + if (!bytes) return null; + try { + return parseSignals(JSON.parse(utf8.decode(bytes))); + } catch { + return null; + } +} +``` + + +--- + +## File: src/shared/types.ts + +```ts +/** Coarse facts about the device and browser. No canvas, audio, WebGL or font probing. */ +export interface DeviceInfo { + /** "Windows" | "macOS" | "iOS" | "Android" | "ChromeOS" | "Linux" | "Other" */ + os: string; + /** "Chrome" | "Edge" | "Firefox" | "Safari" | "Opera" | "Samsung Internet" | "Other" */ + browser: string; + /** Major version only, e.g. "131"; "" when unknown. */ + browserVersion: string; + mobile: boolean; + /** BCP 47 language tag, e.g. "en-IN". */ + language: string; + /** IANA time zone, e.g. "Asia/Kolkata". */ + timezone: string; + /** "1920x1080" (larger side first, so rotating a phone does not change it); "" when unknown. */ + screen: string; +} + +/** + * The security signals a client sends with login and refresh (and may send with every request). + * Collected by `@mgpl-diagnostics/security-kit/browser`, read by `@mgpl-diagnostics/security-kit/server`. + */ +export interface DeviceSignals { + /** Signal format version. */ + v: 1; + /** Random id kept in the browser's persistent storage: one per browser profile. */ + deviceId: string; + /** Random id kept in session storage: one per tab. */ + tabId: string; + /** SHA-256 (hex, 32 chars) of the coarse device attributes; changes when the device's setup changes. */ + fingerprint: string; + device: DeviceInfo; + /** The app's own version, when it sets one. */ + app?: string; +} +``` diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..8592891 --- /dev/null +++ b/llms.txt @@ -0,0 +1,32 @@ +# @mgpl-diagnostics/security-kit + +> Security primitives for both sides of MGPL Diagnostics, with zero dependencies. The browser entry collects device signals (device id, tab id, coarse fingerprint, device info) in any frontend and sends them as the x-mgpl-signals header; the server entry validates them, binds them to a session (hashed ids) and checks later requests (same / changed / other-device / missing / unbound). The server entry also has clientIp behind proxies, security and CORS headers, and log redaction. + +Key facts for agents: +- Entries: "." (shared: types, encode/decode, randomId, sha256Hex, constantTimeEqual), "./browser" (createSignalCollector, deviceInfo, storages), "./server" (readSignals, bindSignals, checkSignals, decide, clientIp, 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. +- Token and session logic stays in auth-kit; security-kit only supplies signals and helpers. + +## Docs + +- [README](README.md): entries, what is collected, use, verdicts, server helpers +- [AGENTS.md](AGENTS.md): map and rules +- [llms-full.txt](llms-full.txt): every doc and source file in one document + +## Source + +- [src/shared/signals.ts](src/shared/signals.ts): encode, decode and validate signals +- [src/browser/collect.ts](src/browser/collect.ts): createSignalCollector +- [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/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 + +## Optional + +- [auth-kit](https://github.com/mgpl-diagnostics/auth-kit) +- [client](https://github.com/mgpl-diagnostics/client) +- [api](https://github.com/mgpl-diagnostics/api) diff --git a/package.json b/package.json new file mode 100644 index 0000000..ef5716d --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "@mgpl-diagnostics/security-kit", + "version": "0.1.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.", + "type": "module", + "license": "SEE LICENSE IN LICENSE", + "repository": "github:mgpl-diagnostics/security-kit", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "AGENTS.md", + "llms.txt", + "LICENSE", + "llms-full.txt" + ], + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.browser.json", + "test": "tsx scripts/test.ts", + "prepare": "tsup", + "llms": "node scripts/llms-full.mjs @mgpl-diagnostics/security-kit README.md AGENTS.md llms.txt package.json src scripts" + }, + "devDependencies": { + "@types/node": "^22.20.5", + "tsup": "^8.5.0", + "tsx": "^4.23.15", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/scripts/check-dist.mjs b/scripts/check-dist.mjs new file mode 100644 index 0000000..5e3b947 --- /dev/null +++ b/scripts/check-dist.mjs @@ -0,0 +1,25 @@ +// After `npm run build`: the main and /browser bundles (and every chunk they import) must not load +// Node.js modules, so they run in browsers, workers and React Native. /server may. +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const seen = new Set(); +const bad = []; +function walk(file) { + if (seen.has(file)) return; + seen.add(file); + const src = readFileSync(file, "utf8"); + for (const m of src.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+)["']/g)) { + const spec = m[1]; + if (spec.startsWith("node:") || /^(crypto|net|fs|path|buffer|os)$/.test(spec)) bad.push(`${file} imports ${spec}`); + else if (spec.startsWith(".")) walk(join(dirname(file), spec)); + } + if (/\bBuffer\./.test(src)) bad.push(`${file} uses Buffer`); +} +walk("dist/index.js"); +walk("dist/browser.js"); +if (bad.length) { + console.error(bad.join("\n")); + process.exit(1); +} +console.log(`dist: ${seen.size} browser-safe files`); diff --git a/scripts/llms-full.mjs b/scripts/llms-full.mjs new file mode 100644 index 0000000..77ff18a --- /dev/null +++ b/scripts/llms-full.mjs @@ -0,0 +1,53 @@ +// Builds llms-full.txt: every doc and source file of the repo in one plain-text document for AI tools. +// node scripts/llms-full.mjs "<title>" <file-or-folder> [file-or-folder …] +// Folders are walked recursively. Output order: README, AGENTS, docs, then the rest alphabetically. +// Skipped: build output, dependencies, lockfiles, generated files, binaries. Files over 150 KB are +// listed (path + size) instead of inlined. CI checks the file is up to date (`npm run llms` + git diff). +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { extname, join, relative } from "node:path"; + +const [title, ...inputs] = process.argv.slice(2); +if (!title || !inputs.length) { + console.error('usage: node scripts/llms-full.mjs "<title>" <file-or-folder> …'); + process.exit(1); +} + +const TEXT = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".md", ".mdx", ".json", ".css", ".yml", ".yaml", ".txt", ".graphql", ".gql", ".sql", ".html"]); +const SKIP_DIRS = new Set(["node_modules", "dist", ".next", ".git", ".turbo", "coverage", "examples", ".pg-test"]); +const SKIP_FILES = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|llms-full\.txt|[^/]*\.generated\.[^/]+|[^/]*\.log)$/; +const MAX = 150 * 1024; +const LANG = { ".ts": "ts", ".tsx": "tsx", ".mts": "ts", ".cts": "ts", ".js": "js", ".mjs": "js", ".cjs": "js", ".json": "json", ".css": "css", ".yml": "yaml", ".yaml": "yaml", ".graphql": "graphql", ".gql": "graphql", ".sql": "sql", ".html": "html" }; + +const posix = (p) => p.split("\\").join("/"); +const files = new Set(); +function walk(p) { + const st = statSync(p); + if (st.isDirectory()) { + for (const name of readdirSync(p)) if (!SKIP_DIRS.has(name)) walk(join(p, name)); + } else if (TEXT.has(extname(p).toLowerCase()) && !SKIP_FILES.test(posix(p))) { + files.add(posix(relative(".", p))); + } +} +for (const input of inputs) walk(input); + +const rank = (f) => (/^README\.md$/i.test(f) ? 0 : /^AGENTS\.md$/i.test(f) ? 1 : /^docs\//.test(f) ? 2 : 3); +const ordered = [...files].sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0)); + +const parts = [`# ${title} — full documentation and source\n\n> Generated by scripts/llms-full.mjs. Do not edit by hand. ${ordered.length} files.\n\n## Contents\n\n${ordered.map((f) => `- ${f}`).join("\n")}\n`]; +const omitted = []; +for (const f of ordered) { + // LF-normalised text, so Windows and Linux checkouts produce byte-identical output + const body = readFileSync(f, "utf8").replace(/\r\n/g, "\n").trim(); + const size = Buffer.byteLength(body); + if (size > MAX) { + omitted.push(`${f} (${(size / 1024).toFixed(0)} KB)`); + parts.push(`\n\n---\n\n## File: ${f}\n\n_Omitted: ${(size / 1024).toFixed(0)} KB. Read the file directly._\n`); + continue; + } + const lang = LANG[extname(f).toLowerCase()]; + const fence = body.includes("```") ? "````" : "```"; + parts.push(`\n\n---\n\n## File: ${f}\n\n${lang ? `${fence}${lang}\n${body}\n${fence}` : body}\n`); +} +const out = parts.join(""); +writeFileSync("llms-full.txt", out); +console.log(`llms-full.txt: ${ordered.length} files, ${(out.length / 1024).toFixed(0)} KB${omitted.length ? `; omitted (too large): ${omitted.join(", ")}` : ""}`); diff --git a/scripts/test.ts b/scripts/test.ts new file mode 100644 index 0000000..c9f7edc --- /dev/null +++ b/scripts/test.ts @@ -0,0 +1,423 @@ +// npm test: every part of security-kit, run on Node.js. The browser part runs against simulated +// environments and storages (it only needs Web Crypto, which Node.js >= 20 has). +import { + canonicalJson, + constantTimeEqual, + decodeSignals, + encodeSignals, + fromBase64Url, + ID_PATTERN, + parseSignals, + randomId, + sha256Hex, + SIGNALS_HEADER, + toBase64Url, + type DeviceSignals, +} from "../src/index"; +import { browserOf, createSignalCollector, deviceInfo, memoryStorage, webStorage, type SignalEnvironment, type SignalStorage } from "../src/browser/index"; +import { + bindSignals, + checkSignals, + clientIp, + corsPolicy, + decide, + maskEmail, + maskPhone, + normalizeIp, + readSignals, + redact, + securityHeaders, +} from "../src/server/index"; + +let passed = 0; +const failures: string[] = []; +async function check(name: string, fn: () => unknown | Promise<unknown>) { + try { + const r = await fn(); + if (r === false) throw new Error("returned false"); + passed++; + } catch (e) { + failures.push(`${name}: ${(e as Error).message}`); + } +} +function eq(a: unknown, b: unknown) { + const x = JSON.stringify(a), y = JSON.stringify(b); + if (x !== y) throw new Error(`expected ${y}, got ${x}`); +} +function ok(v: unknown, msg = "expected truthy") { + if (!v) throw new Error(msg); +} + +// ---------- shared ---------- + +await check("base64url round-trips bytes and UTF-8", () => { + eq(toBase64Url("hello"), "aGVsbG8"); + const s = "नमस्ते ✓ <>"; + eq(new TextDecoder().decode(fromBase64Url(toBase64Url(s))!), s); + const bytes = new Uint8Array([0, 255, 62, 63, 251]); + eq([...fromBase64Url(toBase64Url(bytes))!], [...bytes]); + ok(!toBase64Url(bytes).includes("+") && !toBase64Url(bytes).includes("/") && !toBase64Url(bytes).includes("=")); +}); +await check("fromBase64Url rejects invalid input", () => { + eq(fromBase64Url("a+b/"), null); + eq(fromBase64Url("abcde"), null); + eq(fromBase64Url("ab c"), null); +}); +await check("randomId: 22 base64url chars, unique, fits ID_PATTERN", () => { + const ids = new Set(Array.from({ length: 500 }, () => randomId())); + eq(ids.size, 500); + for (const id of ids) ok(id.length === 22 && ID_PATTERN.test(id), id); +}); +await check("sha256Hex matches the FIPS 180-2 vector", async () => { + eq(await sha256Hex("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); +}); +await check("constantTimeEqual", () => { + ok(constantTimeEqual("abc", "abc")); + ok(!constantTimeEqual("abc", "abd")); + ok(!constantTimeEqual("abc", "abcd")); +}); +await check("canonicalJson sorts keys at every level and drops undefined", () => { + eq(canonicalJson({ b: 1, a: { d: [2, { z: 1, y: 2 }], c: undefined } }), '{"a":{"d":[2,{"y":2,"z":1}]},"b":1}'); +}); + +// ---------- browser: device info ---------- + +const UA = { + chromeWin: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + edgeWin: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.70", + safariIphone: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", + safariMac: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", + chromeIos: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1", + samsung: "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/26.0 Chrome/122.0.0.0 Mobile Safari/537.36", + firefoxLinux: "Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", + opera: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 OPR/115.0.0.0", + chromebook: "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + androidTablet: "Mozilla/5.0 (Linux; Android 13; SM-X700) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", +}; + +const env = (ua: string, extra: Partial<SignalEnvironment["navigator"]> = {}, screen = { width: 1920, height: 1080, colorDepth: 24 }): SignalEnvironment => ({ + navigator: { userAgent: ua, language: "en-IN", platform: "Win32", hardwareConcurrency: 8, deviceMemory: 8, maxTouchPoints: 0, ...extra }, + screen, + timezone: "Asia/Kolkata", +}); + +await check("browser and version from user agents", () => { + eq(browserOf(UA.chromeWin), { browser: "Chrome", browserVersion: "131" }); + eq(browserOf(UA.edgeWin), { browser: "Edge", browserVersion: "131" }); + eq(browserOf(UA.safariIphone), { browser: "Safari", browserVersion: "17" }); + eq(browserOf(UA.safariMac), { browser: "Safari", browserVersion: "17" }); + eq(browserOf(UA.chromeIos), { browser: "Chrome", browserVersion: "131" }); + eq(browserOf(UA.samsung), { browser: "Samsung Internet", browserVersion: "26" }); + eq(browserOf(UA.firefoxLinux), { browser: "Firefox", browserVersion: "133" }); + eq(browserOf(UA.opera), { browser: "Opera", browserVersion: "115" }); + eq(browserOf("curl/8.0"), { browser: "Other", browserVersion: "" }); +}); +await check("OS and form factor", () => { + eq([deviceInfo(env(UA.chromeWin)).os, deviceInfo(env(UA.chromeWin)).mobile], ["Windows", false]); + eq([deviceInfo(env(UA.safariIphone)).os, deviceInfo(env(UA.safariIphone)).mobile], ["iOS", true]); + eq(deviceInfo(env(UA.safariMac)).os, "macOS"); + eq(deviceInfo(env(UA.safariMac, { maxTouchPoints: 5 })).os, "iOS"); // iPadOS reports a Mac UA + eq([deviceInfo(env(UA.samsung)).os, deviceInfo(env(UA.samsung)).mobile], ["Android", true]); + eq([deviceInfo(env(UA.androidTablet)).os, deviceInfo(env(UA.androidTablet)).mobile], ["Android", false]); + eq(deviceInfo(env(UA.firefoxLinux)).os, "Linux"); + eq(deviceInfo(env(UA.chromebook)).os, "ChromeOS"); + eq(deviceInfo(env(UA.chromeWin, { userAgentData: { mobile: true } })).mobile, true); +}); +await check("device info: language, time zone, screen (rotation-proof), empty environment", () => { + const d = deviceInfo(env(UA.chromeWin)); + eq([d.language, d.timezone, d.screen], ["en-IN", "Asia/Kolkata", "1920x1080"]); + eq(deviceInfo(env(UA.safariIphone, {}, { width: 390, height: 844, colorDepth: 24 })).screen, "844x390"); + eq(deviceInfo(env(UA.safariIphone, {}, { width: 844, height: 390, colorDepth: 24 })).screen, "844x390"); + eq(deviceInfo({}), { os: "Other", browser: "Other", browserVersion: "", mobile: false, language: "", timezone: "", screen: "" }); +}); + +// ---------- browser: collector ---------- + +function collector(e: SignalEnvironment, storage = memoryStorage(), tabStorage = memoryStorage(), app?: string) { + return createSignalCollector({ storage, tabStorage, environment: () => e, app }); +} + +await check("collector: valid signals; ids persist across collectors sharing storage", async () => { + const storage = memoryStorage(); + const tab = memoryStorage(); + const a = await collector(env(UA.chromeWin), storage, tab).collect(); + const b = await collector(env(UA.chromeWin), storage, tab).collect(); + ok(parseSignals(a), "parseSignals rejected collected signals"); + eq(a, b); + const otherTab = await collector(env(UA.chromeWin), storage, memoryStorage()).collect(); + eq(otherTab.deviceId, a.deviceId); + ok(otherTab.tabId !== a.tabId, "new tab gets a new tab id"); + ok(/^[0-9a-f]{32}$/.test(a.fingerprint)); +}); +await check("collector: fingerprint ignores browser version, changes with setup", async () => { + const base = await collector(env(UA.chromeWin)).collect(); + const updated = await collector(env(UA.chromeWin.replace("Chrome/131", "Chrome/132"))).collect(); + eq(updated.fingerprint, base.fingerprint); + const newScreen = await collector(env(UA.chromeWin, {}, { width: 2560, height: 1440, colorDepth: 24 })).collect(); + ok(newScreen.fingerprint !== base.fingerprint); + const otherBrowser = await collector(env(UA.firefoxLinux)).collect(); + ok(otherBrowser.fingerprint !== base.fingerprint); +}); +await check("collector: caches, refresh re-reads, reset forgets ids", async () => { + let lang = "en-IN"; + const storage = memoryStorage(), tab = memoryStorage(); + const c = createSignalCollector({ storage, tabStorage: tab, environment: () => env(UA.chromeWin, { language: lang }) }); + const first = await c.collect(); + lang = "hi-IN"; + eq((await c.collect()).device.language, "en-IN"); + const r = await c.refresh(); + eq(r.device.language, "hi-IN"); + eq(r.deviceId, first.deviceId); + await c.reset(); + const after = await c.collect(); + ok(after.deviceId !== first.deviceId && after.tabId !== first.tabId); +}); +await check("collector: headers round-trip through decodeSignals; app is sent", async () => { + const c = collector(env(UA.safariIphone), memoryStorage(), memoryStorage(), "lab-panel@1.4.0"); + const h = await c.headers(); + eq(Object.keys(h), [SIGNALS_HEADER]); + eq(decodeSignals(h[SIGNALS_HEADER]), await c.collect()); + eq((await c.collect()).app, "lab-panel@1.4.0"); + ok((await c.headerValue()).length < 1024, "header stays small"); +}); +await check("collector: replaces a tampered stored id", async () => { + const storage = memoryStorage(); + await storage.set("mgpl.deviceId", "<script>"); + const s = await collector(env(UA.chromeWin), storage).collect(); + ok(ID_PATTERN.test(s.deviceId)); + eq(await storage.get("mgpl.deviceId"), s.deviceId); +}); +await check("collector: async storage (React Native style)", async () => { + const map = new Map<string, string>(); + const asyncStore: SignalStorage = { get: async (k) => map.get(k) ?? null, set: async (k, v) => void map.set(k, v) }; + const a = await createSignalCollector({ storage: asyncStore, tabStorage: memoryStorage(), environment: () => env(UA.samsung) }).collect(); + const b = await createSignalCollector({ storage: asyncStore, tabStorage: memoryStorage(), environment: () => env(UA.samsung) }).collect(); + eq(a.deviceId, b.deviceId); +}); +await check("webStorage: works without storage and when storage throws", async () => { + const g = globalThis as Record<string, unknown>; + const s = webStorage("local"); + s.set("k", "v"); // no localStorage in Node: memory + eq(s.get("k"), "v"); + Object.defineProperty(g, "localStorage", { configurable: true, get() { throw new Error("SecurityError"); } }); + try { + const t = webStorage("local"); + t.set("k", "w"); + eq(t.get("k"), "w"); + const c = await createSignalCollector({ environment: () => env(UA.chromeWin) }).collect(); + ok(parseSignals(c)); + } finally { + delete g.localStorage; + } +}); +await check("webStorage: uses a real Storage when present", () => { + const g = globalThis as Record<string, unknown>; + const data = new Map<string, string>(); + g.sessionStorage = { getItem: (k: string) => data.get(k) ?? null, setItem: (k: string, v: string) => void data.set(k, v), removeItem: (k: string) => void data.delete(k) }; + try { + const s = webStorage("session"); + s.set("a", "1"); + eq(data.get("a"), "1"); + s.remove?.("a"); + eq(data.has("a"), false); + } finally { + delete g.sessionStorage; + } +}); + +// ---------- shared: decoding untrusted input ---------- + +const good: DeviceSignals = { + v: 1, + deviceId: "AAAAAAAAAAAAAAAAAAAAAA", + tabId: "BBBBBBBBBBBBBBBBBBBBBB", + fingerprint: "0123456789abcdef0123456789abcdef", + device: { os: "Windows", browser: "Chrome", browserVersion: "131", mobile: false, language: "en-IN", timezone: "Asia/Kolkata", screen: "1920x1080" }, +}; +const enc = (v: unknown) => toBase64Url(JSON.stringify(v)); + +await check("decodeSignals accepts good signals and drops unknown keys", () => { + eq(decodeSignals(encodeSignals(good)), good); + eq(decodeSignals(enc({ ...good, extra: "x", device: { ...good.device, gpu: "y" } })), good); +}); +await check("decodeSignals rejects malformed, oversized and hostile values", () => { + const bad: unknown[] = [ + { ...good, v: 2 }, + { ...good, deviceId: "short" }, + { ...good, deviceId: "A".repeat(65) }, + { ...good, tabId: "../../etc/passwd!!!!!!!" }, + { ...good, fingerprint: "XYZ" }, + { ...good, fingerprint: "0123456789ABCDEF0123456789ABCDEF" }, + { ...good, device: { ...good.device, os: "<img src=x>" } }, + { ...good, device: { ...good.device, browser: "a\u0000b" } }, + { ...good, device: { ...good.device, language: "x".repeat(65) } }, + { ...good, device: { ...good.device, mobile: "no" } }, + { ...good, device: null }, + { ...good, app: 42 }, + [good], + "string", + ]; + for (const b of bad) eq(decodeSignals(enc(b)), null); + eq(decodeSignals(undefined), null); + eq(decodeSignals(""), null); + eq(decodeSignals("not base64!"), null); + eq(decodeSignals(toBase64Url("{not json")), null); + eq(decodeSignals("A".repeat(2049)), null); + const polluted = decodeSignals(toBase64Url(JSON.stringify(good).replace("{", '{"__proto__":{"polluted":true},'))); + eq(polluted, good); + eq(({} as Record<string, unknown>).polluted, undefined); +}); + +// ---------- server: reading and checking signals ---------- + +await check("readSignals from Fetch Headers, Node headers (any case) and arrays", () => { + const value = encodeSignals(good); + eq(readSignals(new Headers({ [SIGNALS_HEADER]: value })), good); + eq(readSignals({ "x-mgpl-signals": value }), good); + eq(readSignals({ "X-MGPL-Signals": value }), good); + eq(readSignals({ "x-mgpl-signals": [value, "junk"] }), good); + eq(readSignals({}), null); + eq(readSignals(undefined), null); +}); +await check("bindSignals stores no raw ids", () => { + const b = bindSignals(good); + const json = JSON.stringify(b); + ok(!json.includes(good.deviceId) && !json.includes(good.tabId), "raw id in binding"); + eq(b.fingerprint, good.fingerprint); +}); +await check("checkSignals verdicts", () => { + const b = bindSignals(good); + eq(checkSignals(b, good), { verdict: "same", changes: [], sameDevice: true }); + eq(checkSignals(b, { ...good, tabId: "CCCCCCCCCCCCCCCCCCCCCC" }).verdict, "same"); // another tab of the same browser + eq(checkSignals(b, { ...good, device: { ...good.device, browserVersion: "132" } }).verdict, "same"); + eq(checkSignals(b, { ...good, fingerprint: "f".repeat(32), device: { ...good.device, screen: "2560x1440" } }), { + verdict: "changed", + changes: ["fingerprint", "screen"], + sameDevice: true, + }); + eq(checkSignals(b, { ...good, deviceId: "ZZZZZZZZZZZZZZZZZZZZZZ" }), { verdict: "other-device", changes: ["deviceId"], sameDevice: false }); + eq(checkSignals(b, null).verdict, "missing"); + eq(checkSignals(null, good).verdict, "unbound"); +}); +await check("decide applies the policy", () => { + const b = bindSignals(good); + eq(decide(checkSignals(b, good)), { allow: true, log: false }); + eq(decide(checkSignals(b, { ...good, fingerprint: "f".repeat(32) })), { allow: true, log: true }); + eq(decide(checkSignals(b, { ...good, deviceId: "ZZZZZZZZZZZZZZZZZZZZZZ" })), { allow: false, log: true }); + eq(decide(checkSignals(b, { ...good, deviceId: "ZZZZZZZZZZZZZZZZZZZZZZ" }), { rejectOtherDevice: false }), { allow: true, log: true }); + eq(decide(checkSignals(b, null)), { allow: true, log: true }); + eq(decide(checkSignals(b, null), { requireSignals: true }), { allow: false, log: true }); + eq(decide(checkSignals(null, null)), { allow: true, log: false }); +}); + +// ---------- server: client IP ---------- + +await check("normalizeIp", () => { + eq(normalizeIp("::ffff:10.0.0.7"), "10.0.0.7"); + eq(normalizeIp("203.0.113.9:51234"), "203.0.113.9"); + eq(normalizeIp("[2001:DB8::1]:443"), "2001:db8::1"); + eq(normalizeIp("2001:db8::1"), "2001:db8::1"); + eq(normalizeIp("not-an-ip"), null); + eq(normalizeIp("1.2.3.4, 5.6.7.8"), null); + eq(normalizeIp(undefined), null); +}); +await check("clientIp ignores X-Forwarded-For unless proxies are trusted", () => { + eq(clientIp({ remoteAddress: "::ffff:10.0.0.2", headers: { "x-forwarded-for": "1.1.1.1" } }), "10.0.0.2"); +}); +await check("clientIp with trusted proxies never takes a spoofed left-hand entry", () => { + // client 203.0.113.9 sends a fake XFF; the load balancer (10.0.0.2) appends the real address + eq(clientIp({ remoteAddress: "10.0.0.2", headers: { "x-forwarded-for": "6.6.6.6, 203.0.113.9" }, trustedHops: 1 }), "203.0.113.9"); + // CDN → load balancer → server + eq(clientIp({ remoteAddress: "10.0.0.2", headers: { "x-forwarded-for": "6.6.6.6, 203.0.113.9, 198.51.100.1" }, trustedHops: 2 }), "203.0.113.9"); + eq(clientIp({ remoteAddress: "10.0.0.2", headers: new Headers({ "x-forwarded-for": "203.0.113.9" }), trustedHops: 1 }), "203.0.113.9"); + eq(clientIp({ remoteAddress: "10.0.0.2", trustedHops: 1 }), "10.0.0.2"); + eq(clientIp({ remoteAddress: "10.0.0.2", headers: { "x-forwarded-for": "garbage" }, trustedHops: 1 }), null); +}); + +// ---------- server: headers ---------- + +await check("securityHeaders defaults and options", () => { + const h = securityHeaders(); + eq(h["strict-transport-security"], "max-age=31536000; includeSubDomains"); + eq(h["x-content-type-options"], "nosniff"); + eq(h["cache-control"], "no-store"); + ok(h["content-security-policy"]!.includes("default-src 'none'")); + const dev = securityHeaders({ hsts: false, csp: false, noStore: false }); + ok(!("strict-transport-security" in dev) && !("content-security-policy" in dev) && !("cache-control" in dev)); +}); +await check("corsPolicy: exact, subdomain patterns, schemes, null origin", () => { + const cors = corsPolicy({ origins: ["https://lab.example.in", "https://*.mgpl.example"] }); + ok(cors.allowed("https://lab.example.in")); + ok(cors.allowed("https://panel.mgpl.example")); + ok(cors.allowed("https://a.b.mgpl.example")); + ok(!cors.allowed("https://mgpl.example"), "bare domain"); + ok(!cors.allowed("http://panel.mgpl.example"), "scheme"); + ok(!cors.allowed("https://evilmgpl.example"), "suffix without dot"); + ok(!cors.allowed("https://mgpl.example.evil.com"), "domain as prefix"); + ok(!cors.allowed("https://lab.example.in.evil.com")); + ok(!cors.allowed("null")); + ok(!cors.allowed(undefined)); + eq(cors.headers("https://evil.com"), { vary: "Origin" }); + const pre = cors.headers("https://lab.example.in", true); + eq(pre["access-control-allow-origin"], "https://lab.example.in"); + ok(pre["access-control-allow-headers"]!.includes(SIGNALS_HEADER)); + ok(!("access-control-allow-credentials" in pre)); +}); +await check("corsPolicy refuses credentials with '*'", () => { + let threw = false; + try { + corsPolicy({ origins: ["*"], credentials: true }); + } catch { + threw = true; + } + ok(threw); + eq(corsPolicy({ origins: ["*"] }).headers("https://any.site")["access-control-allow-origin"], "*"); +}); + +// ---------- server: redaction ---------- + +await check("redact hides secrets at any depth and keeps the rest", () => { + const input = { + email: "kavita.sharma@example.com", + password: "hunter2", + nested: { refresh_token: "r", "X-Api-Key": "k", list: [{ newPassword: "p", ok: 1 }], stripeSecret: "s" }, + headers: { authorization: "Bearer abc", cookie: "sid=1", accept: "*/*" }, + note: "Bearer xyz.abc.def", + when: new Date(0), + }; + const out = redact(input); + eq(out.password, "[redacted]"); + eq(out.nested.refresh_token, "[redacted]"); + eq(out.nested["X-Api-Key"], "[redacted]"); + eq(out.nested.stripeSecret, "[redacted]"); + eq(out.nested.list[0], { newPassword: "[redacted]", ok: 1 }); + eq(out.headers, { authorization: "[redacted]", cookie: "[redacted]", accept: "*/*" }); + eq(out.note, "Bearer [redacted]"); + eq(out.email, "kavita.sharma@example.com"); + ok(out.when instanceof Date); + eq(input.password, "hunter2"); // the input is untouched +}); +await check("redact: cycles, Headers, Map, errors, extra keys, depth", () => { + const a: Record<string, unknown> = { name: "x" }; + a.self = a; + eq(redact(a), { name: "x", self: "[circular]" }); + eq(redact(new Headers({ authorization: "Bearer t", "content-type": "application/json" })), { authorization: "[redacted]", "content-type": "application/json" }); + eq(redact(new Map([["token", "t"], ["id", "1"]])), { token: "[redacted]", id: "1" }); + eq(redact({ uhid: "MGPL-1", phone: "9876543210" }, { keys: ["phone"] }), { uhid: "MGPL-1", phone: "[redacted]" }); + eq(redact(new Error("Bearer abc failed")), { name: "Error", message: "Bearer [redacted]" }); + eq(redact({ a: { b: { c: 1 } } }, { maxDepth: 2 }), { a: { b: "[depth]" } }); +}); +await check("maskEmail and maskPhone", () => { + eq(maskEmail("kavita.sharma@example.com"), "k***a@example.com"); + eq(maskEmail("ab@example.com"), "a*@example.com"); + eq(maskEmail("nope"), ""); + eq(maskPhone("+91 98765 43210"), "******3210"); + eq(maskPhone("98765 43210"), "******3210"); + eq(maskPhone(""), ""); +}); + +console.log(`${passed} passed, ${failures.length} failed`); +if (failures.length) { + for (const f of failures) console.error(` ✗ ${f}`); + process.exit(1); +} diff --git a/src/browser/collect.ts b/src/browser/collect.ts new file mode 100644 index 0000000..687245d --- /dev/null +++ b/src/browser/collect.ts @@ -0,0 +1,88 @@ +import { ID_PATTERN } from "../shared/constants"; +import { canonicalJson, randomId, sha256Hex } from "../shared/crypto"; +import { encodeSignals, signalHeaders } from "../shared/signals"; +import type { DeviceSignals } from "../shared/types"; +import { defaultEnvironment, deviceInfo, fingerprintComponents, type SignalEnvironment } from "./device"; +import { webStorage, type SignalStorage } from "./storage"; + +export interface SignalCollectorOptions { + /** Keeps the device id. Default: localStorage (memory when unavailable). */ + storage?: SignalStorage; + /** Keeps the tab id. Default: sessionStorage (memory when unavailable). */ + tabStorage?: SignalStorage; + /** Storage key prefix. Default "mgpl.". */ + prefix?: string; + /** The app's version, sent as `app`. */ + app?: string; + /** Where device facts come from. Default: globalThis (navigator, screen, Intl). */ + environment?: () => SignalEnvironment; +} + +export interface SignalCollector { + /** The signals; ids are created on first use and then reused. Cached until `refresh()`. */ + collect(): Promise<DeviceSignals>; + /** `{ "x-mgpl-signals": "…" }` for a fetch / GraphQL request. */ + headers(): Promise<Record<string, string>>; + /** The encoded header value alone. */ + headerValue(): Promise<string>; + /** Re-reads the device facts (after a resize or a language change, for example). */ + refresh(): Promise<DeviceSignals>; + /** Forgets the device and tab ids (for "forget this device" or after a sign-out on a shared PC). */ + reset(): Promise<void>; +} + +/** + * Collects the security signals the backend checks at login and refresh. Framework-agnostic: call + * `headers()` from any fetch wrapper, GraphQL client or query library. + * + * const signals = createSignalCollector({ app: "lab-panel@1.4.0" }); + * fetch(url, { headers: { ...(await signals.headers()), authorization } }); + */ +export function createSignalCollector(o: SignalCollectorOptions = {}): SignalCollector { + const storage = o.storage ?? webStorage("local"); + const tabStorage = o.tabStorage ?? webStorage("session"); + const prefix = o.prefix ?? "mgpl."; + const env = o.environment ?? defaultEnvironment; + let cached: Promise<DeviceSignals> | null = null; + + async function idFrom(store: SignalStorage, key: string): Promise<string> { + const existing = await store.get(prefix + key); + if (typeof existing === "string" && ID_PATTERN.test(existing)) return existing; + const id = randomId(); + await store.set(prefix + key, id); + return id; + } + + async function build(): Promise<DeviceSignals> { + const e = env(); + const [deviceId, tabId, hash] = await Promise.all([ + idFrom(storage, "deviceId"), + idFrom(tabStorage, "tabId"), + sha256Hex(canonicalJson(fingerprintComponents(e))), + ]); + return { v: 1, deviceId, tabId, fingerprint: hash.slice(0, 32), device: deviceInfo(e), ...(o.app ? { app: o.app.slice(0, 64) } : {}) }; + } + + const collect = () => { + cached ??= build().catch((err) => { + cached = null; // retry next time + throw err; + }); + return cached; + }; + + return { + collect, + headers: async () => signalHeaders(await collect()), + headerValue: async () => encodeSignals(await collect()), + refresh() { + cached = null; + return collect(); + }, + async reset() { + cached = null; + await storage.remove?.(prefix + "deviceId"); + await tabStorage.remove?.(prefix + "tabId"); + }, + }; +} diff --git a/src/browser/device.ts b/src/browser/device.ts new file mode 100644 index 0000000..fb250e1 --- /dev/null +++ b/src/browser/device.ts @@ -0,0 +1,105 @@ +import type { DeviceInfo } from "../shared/types"; + +/** + * The parts of the browser environment the collector reads. Defaults to `globalThis`; tests and + * non-browser apps (React Native, Electron main) pass their own. + */ +export interface SignalEnvironment { + navigator?: { + userAgent?: string; + language?: string; + platform?: string; + hardwareConcurrency?: number; + deviceMemory?: number; + maxTouchPoints?: number; + userAgentData?: { mobile?: boolean; platform?: string }; + }; + screen?: { width?: number; height?: number; colorDepth?: number }; + /** IANA time zone; defaults to Intl. */ + timezone?: string; +} + +export function defaultEnvironment(): SignalEnvironment { + const g = globalThis as unknown as { navigator?: SignalEnvironment["navigator"]; screen?: SignalEnvironment["screen"] }; + let timezone = ""; + try { + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""; + } catch { + /* no Intl time zones */ + } + return { navigator: g.navigator, screen: g.screen, timezone }; +} + +const cut = (s: string) => s.slice(0, 64); + +function osOf(ua: string, touchPoints: number, hint?: string): string { + if (/Windows/i.test(ua) || hint === "Windows") return "Windows"; + if (/Android/i.test(ua) || hint === "Android") return "Android"; + if (/iPhone|iPad|iPod/i.test(ua) || hint === "iOS") return "iOS"; + if (/CrOS/i.test(ua) || hint === "Chrome OS" || hint === "ChromeOS") return "ChromeOS"; + // iPadOS 13+ reports itself as a Mac; a Mac has no touch screen + if (/Macintosh|Mac OS X/i.test(ua) || hint === "macOS") return touchPoints > 1 ? "iOS" : "macOS"; + if (/Linux/i.test(ua) || hint === "Linux") return "Linux"; + return "Other"; +} + +const BROWSERS: [RegExp, string][] = [ + [/Edg(?:e|A|iOS)?\/(\d+)/, "Edge"], + [/(?:OPR|Opera)\/(\d+)/, "Opera"], + [/SamsungBrowser\/(\d+)/, "Samsung Internet"], + [/(?:Firefox|FxiOS)\/(\d+)/, "Firefox"], + [/(?:Chrome|CriOS)\/(\d+)/, "Chrome"], + [/Version\/(\d+)[^ ]* (?:Mobile\/\S+ )?Safari\//, "Safari"], +]; + +/** Browser family and major version from a user-agent string. */ +export function browserOf(ua: string): { browser: string; browserVersion: string } { + for (const [re, name] of BROWSERS) { + const m = re.exec(ua); + if (m) return { browser: name, browserVersion: m[1] ?? "" }; + } + return { browser: "Other", browserVersion: "" }; +} + +/** Coarse `DeviceInfo` from the environment. Never throws; unknown values are "". */ +export function deviceInfo(env: SignalEnvironment = defaultEnvironment()): DeviceInfo { + const nav = env.navigator ?? {}; + const ua = typeof nav.userAgent === "string" ? nav.userAgent : ""; + const touch = typeof nav.maxTouchPoints === "number" ? nav.maxTouchPoints : 0; + const os = osOf(ua, touch, nav.userAgentData?.platform); + const { browser, browserVersion } = browserOf(ua); + const mobile = typeof nav.userAgentData?.mobile === "boolean" ? nav.userAgentData.mobile : /Mobi|iPhone|iPod|Android.*Mobile/i.test(ua); + const w = Math.round(env.screen?.width ?? 0), h = Math.round(env.screen?.height ?? 0); + return { + os, + browser, + browserVersion: cut(browserVersion), + mobile, + language: cut(typeof nav.language === "string" ? nav.language : ""), + timezone: cut(env.timezone ?? ""), + screen: w > 0 && h > 0 ? `${Math.max(w, h)}x${Math.min(w, h)}` : "", + }; +} + +/** + * The attributes the fingerprint hashes. Deliberately coarse and stable: no browser version (it changes + * with every update), no canvas, audio, WebGL or font probing. + */ +export function fingerprintComponents(env: SignalEnvironment = defaultEnvironment()): Record<string, string | number | boolean> { + const info = deviceInfo(env); + const nav = env.navigator ?? {}; + const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0); + return { + os: info.os, + browser: info.browser, + mobile: info.mobile, + platform: typeof nav.platform === "string" ? nav.platform.slice(0, 64) : "", + language: info.language, + timezone: info.timezone, + screen: info.screen, + colorDepth: num(env.screen?.colorDepth), + cores: num(nav.hardwareConcurrency), + memory: num(nav.deviceMemory), + touch: num(nav.maxTouchPoints), + }; +} diff --git a/src/browser/index.ts b/src/browser/index.ts new file mode 100644 index 0000000..991547d --- /dev/null +++ b/src/browser/index.ts @@ -0,0 +1,5 @@ +// "@mgpl-diagnostics/security-kit/browser": collects device signals in any frontend. No Node.js APIs. +export { createSignalCollector, type SignalCollector, type SignalCollectorOptions } from "./collect"; +export { browserOf, defaultEnvironment, deviceInfo, fingerprintComponents, type SignalEnvironment } from "./device"; +export { memoryStorage, webStorage, type SignalStorage } from "./storage"; +export * from "../index"; diff --git a/src/browser/storage.ts b/src/browser/storage.ts new file mode 100644 index 0000000..ebbf337 --- /dev/null +++ b/src/browser/storage.ts @@ -0,0 +1,55 @@ +/** Where the collector keeps its ids. Sync or async, so React Native's AsyncStorage fits too. */ +export interface SignalStorage { + get(key: string): string | null | undefined | Promise<string | null | undefined>; + set(key: string, value: string): void | Promise<void>; + remove?(key: string): void | Promise<void>; +} + +/** In-memory storage: ids last as long as the page (or process). */ +export function memoryStorage(): SignalStorage { + const map = new Map<string, string>(); + return { get: (k) => map.get(k), set: (k, v) => void map.set(k, v), remove: (k) => void map.delete(k) }; +} + +/** + * Wraps `localStorage` or `sessionStorage`. Falls back to memory when the storage is missing or throws + * (private mode, blocked site data, server rendering). + */ +export function webStorage(kind: "local" | "session"): SignalStorage { + const fallback = memoryStorage(); + const store = (): Storage | null => { + try { + const s = (globalThis as Record<string, unknown>)[kind === "local" ? "localStorage" : "sessionStorage"] as Storage | undefined; + return s ?? null; + } catch { + return null; + } + }; + return { + get(k) { + try { + const v = store()?.getItem(k); + if (v != null) return v; + } catch { + /* blocked */ + } + return fallback.get(k); + }, + set(k, v) { + fallback.set(k, v); + try { + store()?.setItem(k, v); + } catch { + /* quota or blocked: memory keeps it */ + } + }, + remove(k) { + fallback.remove?.(k); + try { + store()?.removeItem(k); + } catch { + /* blocked */ + } + }, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d3698d5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +// "@mgpl-diagnostics/security-kit": the shared part. Runs in browsers, Node.js, workers and React Native. +export type { DeviceInfo, DeviceSignals } from "./shared/types"; +export { FINGERPRINT_PATTERN, ID_PATTERN, SIGNAL_LIMITS, SIGNALS_HEADER } from "./shared/constants"; +export { canonicalJson, constantTimeEqual, fromBase64Url, randomBytes, randomId, sha256Hex, toBase64Url } from "./shared/crypto"; +export { decodeSignals, encodeSignals, parseDeviceInfo, parseSignals, signalHeaders } from "./shared/signals"; diff --git a/src/server/headers.ts b/src/server/headers.ts new file mode 100644 index 0000000..cd48232 --- /dev/null +++ b/src/server/headers.ts @@ -0,0 +1,90 @@ +import { SIGNALS_HEADER } from "../shared/constants"; + +export interface SecurityHeaderOptions { + /** HSTS max-age in seconds; false to omit (plain-HTTP development). Default one year. */ + hsts?: number | false; + /** + * Content-Security-Policy. Default for a JSON / GraphQL API: nothing may load and nothing may frame it. + * Pass a string for pages (GraphiQL needs its own), or false to omit. + */ + csp?: string | false; + /** Cache-Control: no-store. Default true (responses carry personal data and tokens). */ + noStore?: boolean; +} + +export const API_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"; + +/** Response headers for the API. Set them on every response (a Fastify onSend hook, for example). */ +export function securityHeaders(o: SecurityHeaderOptions = {}): Record<string, string> { + const h: Record<string, string> = { + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + "referrer-policy": "no-referrer", + "cross-origin-opener-policy": "same-origin", + "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + "x-dns-prefetch-control": "off", + }; + const hsts = o.hsts ?? 31_536_000; + if (hsts !== false) h["strict-transport-security"] = `max-age=${Math.max(0, Math.floor(hsts))}; includeSubDomains`; + const csp = o.csp ?? API_CSP; + if (csp !== false) h["content-security-policy"] = csp; + if (o.noStore ?? true) h["cache-control"] = "no-store"; + return h; +} + +export interface CorsOptions { + /** + * Allowed origins: exact ("https://lab.example.in"), a subdomain pattern ("https://*.example.in", which + * does not match the bare domain), or "*" (any origin, and then never credentials). + */ + origins: readonly string[]; + /** Extra request headers to allow (authorization, content-type and x-mgpl-signals are always allowed). */ + allowHeaders?: readonly string[]; + methods?: readonly string[]; + /** Access-Control-Allow-Credentials (cookies). Default false: the API uses bearer tokens. */ + credentials?: boolean; + /** Preflight cache, seconds. Default 600. */ + maxAge?: number; +} + +function matches(pattern: string, origin: string): boolean { + if (pattern === origin) return true; + const m = /^(https?):\/\/\*\.(.+)$/.exec(pattern); + if (!m) return false; + let url: URL; + try { + url = new URL(origin); + } catch { + return false; + } + if (url.origin !== origin || `${m[1]}:` !== url.protocol) return false; + const host = url.port ? `${url.hostname}:${url.port}` : url.hostname; + return host.endsWith(`.${m[2]}`) && host.length > m[2]!.length + 1; +} + +/** A CORS policy: `allowed(origin)` and the response `headers(origin)` (empty when not allowed). */ +export function corsPolicy(o: CorsOptions) { + const any = o.origins.includes("*"); + if (any && o.credentials) throw new Error("security-kit: CORS cannot allow credentials for any origin ('*')"); + const allowHeaders = [...new Set(["authorization", "content-type", SIGNALS_HEADER, ...(o.allowHeaders ?? []).map((h) => h.toLowerCase())])]; + const methods = o.methods ?? ["GET", "POST", "OPTIONS"]; + + const allowed = (origin: string | null | undefined): origin is string => + typeof origin === "string" && origin !== "null" && (any || o.origins.some((p) => matches(p, origin))); + + return { + allowed, + /** Headers for the response; `preflight` adds the Allow-Methods / Allow-Headers / Max-Age set. */ + headers(origin: string | null | undefined, preflight = false): Record<string, string> { + if (!allowed(origin)) return { vary: "Origin" }; + const h: Record<string, string> = { "access-control-allow-origin": any ? "*" : origin, vary: "Origin" }; + if (o.credentials) h["access-control-allow-credentials"] = "true"; + if (preflight) { + h["access-control-allow-methods"] = methods.join(", "); + h["access-control-allow-headers"] = allowHeaders.join(", "); + h["access-control-max-age"] = String(o.maxAge ?? 600); + } + return h; + }, + }; +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..802f126 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,18 @@ +// "@mgpl-diagnostics/security-kit/server": the backend part (Node.js >= 20). +export { + bindSignals, + checkSignals, + decide, + hashSignalId, + headerValue, + readSignals, + type HeaderSource, + type SignalBinding, + type SignalCheck, + type SignalPolicy, + type SignalVerdict, +} from "./signals"; +export { clientIp, normalizeIp, type ClientIpOptions } from "./ip"; +export { API_CSP, corsPolicy, securityHeaders, type CorsOptions, type SecurityHeaderOptions } from "./headers"; +export { maskEmail, maskPhone, REDACTED, redact, SENSITIVE_KEYS, type RedactOptions } from "./redact"; +export * from "../index"; diff --git a/src/server/ip.ts b/src/server/ip.ts new file mode 100644 index 0000000..b34e184 --- /dev/null +++ b/src/server/ip.ts @@ -0,0 +1,38 @@ +import { isIP } from "node:net"; +import { headerValue, type HeaderSource } from "./signals"; + +/** "::ffff:10.0.0.1" → "10.0.0.1"; strips brackets and a port; null when it is not an IP address. */ +export function normalizeIp(value: string | undefined | null): string | null { + if (!value) return null; + let v = value.trim(); + if (v.startsWith("[")) v = v.slice(1, v.indexOf("]") > 0 ? v.indexOf("]") : undefined); // "[::1]:443" + else if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(v)) v = v.slice(0, v.lastIndexOf(":")); // "1.2.3.4:5678" + if (/^::ffff:\d{1,3}(\.\d{1,3}){3}$/i.test(v)) v = v.slice(7); + return isIP(v) ? v.toLowerCase() : null; +} + +export interface ClientIpOptions { + /** The socket's peer address (Fastify `request.socket.remoteAddress`, Node `req.socket.remoteAddress`). */ + remoteAddress?: string | null; + headers?: HeaderSource; + /** + * How many reverse proxies in front of the server append to X-Forwarded-For (a load balancer = 1, + * CDN + load balancer = 2). Default 0: the header is ignored, because any client can send it. + */ + trustedHops?: number; +} + +/** + * The client's IP address. With `trustedHops` = n, walks X-Forwarded-For from the right and takes the + * address the outermost trusted proxy saw, so a spoofed left-hand entry is never used. + */ +export function clientIp(o: ClientIpOptions): string | null { + const peer = normalizeIp(o.remoteAddress); + const hops = Math.max(0, Math.floor(o.trustedHops ?? 0)); + if (hops === 0) return peer; + const xff = headerValue(o.headers, "x-forwarded-for"); + const chain = (xff ? xff.split(",").map((s) => s.trim()).filter(Boolean) : []).concat(peer ? [peer] : []); + // chain = [client?, …, proxy1, peer]; the trusted part is the last `hops` entries + const idx = chain.length - 1 - hops; + return idx >= 0 ? normalizeIp(chain[idx]) : normalizeIp(chain[0]); +} diff --git a/src/server/redact.ts b/src/server/redact.ts new file mode 100644 index 0000000..eb336cf --- /dev/null +++ b/src/server/redact.ts @@ -0,0 +1,74 @@ +/** Keys (compared without case, "-" or "_") whose values are always hidden. */ +export const SENSITIVE_KEYS = [ + "password", "newpassword", "currentpassword", "oldpassword", "passwordhash", + "token", "accesstoken", "refreshtoken", "idtoken", "resettoken", "sessiontoken", + "authorization", "proxyauthorization", "cookie", "setcookie", + "secret", "clientsecret", "jwtsecret", "apikey", "xapikey", "privatekey", + "otp", "pin", "cvv", "cardnumber", +] as const; + +const SUFFIXES = ["password", "token", "secret", "apikey"]; +export const REDACTED = "[redacted]"; + +const norm = (k: string) => k.toLowerCase().replace(/[-_]/g, ""); + +export interface RedactOptions { + /** More keys to hide (any form: "x-api-key", "myField"). */ + keys?: readonly string[]; + /** Maximum depth; deeper values become "[depth]". Default 8. */ + maxDepth?: number; +} + +/** + * A copy of `value` with secrets replaced by "[redacted]", for logs and error reports. Handles nested + * objects, arrays, Headers-like maps and cycles. Strings that look like bearer tokens are hidden too. + */ +export function redact<T>(value: T, o: RedactOptions = {}): T { + const extra = new Set((o.keys ?? []).map(norm)); + const exact = new Set<string>([...SENSITIVE_KEYS, ...extra]); + const maxDepth = o.maxDepth ?? 8; + const seen = new WeakSet<object>(); + const sensitive = (k: string) => { + const n = norm(k); + return exact.has(n) || SUFFIXES.some((s) => n.endsWith(s)); + }; + + const walk = (v: unknown, depth: number): unknown => { + if (typeof v === "string") return /^Bearer\s+\S+/i.test(v) ? "Bearer [redacted]" : v; + if (v === null || typeof v !== "object") return v; + if (v instanceof Date) return v; + if (seen.has(v)) return "[circular]"; + if (depth >= maxDepth) return "[depth]"; + seen.add(v); + if (Array.isArray(v)) return v.map((x) => walk(x, depth + 1)); + if (v instanceof Map) return Object.fromEntries([...v].map(([k, x]) => [k, sensitive(String(k)) ? REDACTED : walk(x, depth + 1)])); + if (typeof (v as { forEach?: unknown }).forEach === "function" && typeof (v as { get?: unknown }).get === "function") { + // Fetch Headers + const out: Record<string, unknown> = {}; + (v as Headers).forEach((x, k) => (out[k] = sensitive(k) ? REDACTED : walk(x, depth + 1))); + return out; + } + if (v instanceof Error) return { name: v.name, message: walk(v.message, depth + 1) }; + const out: Record<string, unknown> = {}; + for (const [k, x] of Object.entries(v)) out[k] = sensitive(k) ? REDACTED : walk(x, depth + 1); + return out; + }; + return walk(value, 0) as T; +} + +/** "kavita.sharma@example.com" → "k***a@example.com". Returns "" for anything that is not an email. */ +export function maskEmail(email: string): string { + const at = email.lastIndexOf("@"); + if (at < 1) return ""; + const user = email.slice(0, at); + const shown = user.length <= 2 ? `${user[0]}*` : `${user[0]}***${user[user.length - 1]}`; + return `${shown}${email.slice(at)}`; +} + +/** Keeps the last `visible` digits: "+91 98765 43210" → "******3210". */ +export function maskPhone(phone: string, visible = 4): string { + const digits = phone.replace(/\D/g, ""); + if (!digits) return ""; + const keep = Math.min(visible, Math.max(0, digits.length - 4)); + return "*".repeat(digits.length - keep).slice(-6) + digits.slice(digits.length - keep); +} diff --git a/src/server/signals.ts b/src/server/signals.ts new file mode 100644 index 0000000..dc121df --- /dev/null +++ b/src/server/signals.ts @@ -0,0 +1,111 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import { SIGNALS_HEADER } from "../shared/constants"; +import { decodeSignals } from "../shared/signals"; +import type { DeviceInfo, DeviceSignals } from "../shared/types"; + +/** Request headers as Fetch `Headers`, Node's `IncomingHttpHeaders`, or a plain record. */ +export type HeaderSource = Headers | { get(name: string): string | null } | Record<string, string | string[] | undefined>; + +/** One header value (the first, when repeated); lookup is case-insensitive for records. */ +export function headerValue(headers: HeaderSource | undefined, name: string): string | undefined { + if (!headers) return undefined; + if (typeof (headers as { get?: unknown }).get === "function") return (headers as { get(n: string): string | null }).get(name) ?? undefined; + const rec = headers as Record<string, string | string[] | undefined>; + const lower = name.toLowerCase(); + const key = lower in rec ? lower : Object.keys(rec).find((k) => k.toLowerCase() === lower); + const v = key === undefined ? undefined : rec[key]; + return Array.isArray(v) ? v[0] : v; +} + +/** The request's `DeviceSignals`, or null when the header is missing or invalid. Never throws. */ +export function readSignals(headers: HeaderSource | undefined): DeviceSignals | null { + return decodeSignals(headerValue(headers, SIGNALS_HEADER)); +} + +/** SHA-256 (base64url) of an id, for storage: the raw device id never sits in the database. */ +export function hashSignalId(id: string): string { + return createHash("sha256").update(id, "utf8").digest("base64url"); +} + +/** + * What the server keeps on a session (and its refresh token) to recognise the device later. + * Store it as it is; it holds no raw id. + */ +export interface SignalBinding { + deviceIdHash: string; + fingerprint: string; + device: DeviceInfo; + /** Tab id at login, hashed; informational (a refresh may come from another tab of the same browser). */ + tabIdHash: string; +} + +export function bindSignals(signals: DeviceSignals): SignalBinding { + return { + deviceIdHash: hashSignalId(signals.deviceId), + fingerprint: signals.fingerprint, + device: { ...signals.device }, + tabIdHash: hashSignalId(signals.tabId), + }; +} + +/** + * - `same`: same device id and fingerprint. + * - `changed`: same device id, but the fingerprint, OS, browser or form factor changed (log it; a browser + * update, new screen or language change does this). + * - `other-device`: a different device id (reject: the token was copied to another browser). + * - `missing`: the request has no valid signals but the session has a binding. + * - `unbound`: the session has no binding (created before signals existed, or by a non-browser client). + */ +export type SignalVerdict = "same" | "changed" | "other-device" | "missing" | "unbound"; + +export interface SignalCheck { + verdict: SignalVerdict; + /** What differs: "deviceId", "fingerprint", "os", "browser", "mobile", "timezone", "language", "screen". */ + changes: string[]; + /** True for "same" and "changed"; false otherwise. A caller decides what to do with "missing"/"unbound". */ + sameDevice: boolean; +} + +const eq = (a: string, b: string) => { + const x = Buffer.from(a), y = Buffer.from(b); + return x.length === y.length && timingSafeEqual(x, y); +}; + +/** Compares a stored binding with a request's signals. Pure; never throws. */ +export function checkSignals(binding: SignalBinding | null | undefined, incoming: DeviceSignals | null | undefined): SignalCheck { + if (!binding) return { verdict: "unbound", changes: [], sameDevice: false }; + if (!incoming) return { verdict: "missing", changes: [], sameDevice: false }; + if (!eq(binding.deviceIdHash, hashSignalId(incoming.deviceId))) return { verdict: "other-device", changes: ["deviceId"], sameDevice: false }; + const changes: string[] = []; + if (binding.fingerprint !== incoming.fingerprint) changes.push("fingerprint"); + for (const k of ["os", "browser", "mobile", "timezone", "language", "screen"] as const) { + if (binding.device?.[k] !== incoming.device[k]) changes.push(k); + } + return { verdict: changes.length ? "changed" : "same", changes, sameDevice: true }; +} + +export interface SignalPolicy { + /** Reject a session used with another device id. Default true. */ + rejectOtherDevice?: boolean; + /** Reject when signals are missing but the session is bound. Default false (old clients keep working). */ + requireSignals?: boolean; +} + +/** + * Applies a policy to a check: `allow` false means end the session; `log` true means write an audit + * entry (every verdict except "same" and "unbound"). + */ +export function decide(check: SignalCheck, policy: SignalPolicy = {}): { allow: boolean; log: boolean } { + const rejectOther = policy.rejectOtherDevice ?? true; + switch (check.verdict) { + case "same": + case "unbound": + return { allow: true, log: false }; + case "changed": + return { allow: true, log: true }; + case "other-device": + return { allow: !rejectOther, log: true }; + case "missing": + return { allow: !policy.requireSignals, log: true }; + } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 0000000..1647d5b --- /dev/null +++ b/src/shared/constants.ts @@ -0,0 +1,15 @@ +/** HTTP header that carries the encoded `DeviceSignals` (see `encodeSignals`). */ +export const SIGNALS_HEADER = "x-mgpl-signals"; + +/** Limits enforced by `decodeSignals`; anything larger or malformed is rejected. */ +export const SIGNAL_LIMITS = { + /** Encoded header value, in characters. */ + header: 2048, + /** Any single string inside DeviceInfo, and `app`. */ + field: 64, +} as const; + +/** Ids are 16 to 64 base64url characters (a UUID with or without dashes also fits). */ +export const ID_PATTERN = /^[A-Za-z0-9_-]{16,64}$/; +/** Fingerprints are 32 lowercase hex characters. */ +export const FINGERPRINT_PATTERN = /^[0-9a-f]{32}$/; diff --git a/src/shared/crypto.ts b/src/shared/crypto.ts new file mode 100644 index 0000000..bc92fbe --- /dev/null +++ b/src/shared/crypto.ts @@ -0,0 +1,79 @@ +// Primitives that run unchanged in browsers, Node.js >= 20, workers and React Native with Web Crypto: +// only globalThis.crypto, TextEncoder, btoa and atob. No Buffer, no node: imports. + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function webCrypto(): Crypto { + const c = (globalThis as { crypto?: Crypto }).crypto; + if (!c?.getRandomValues) throw new Error("security-kit: Web Crypto (globalThis.crypto) is not available"); + return c; +} + +/** base64url (RFC 4648 §5, no padding) of bytes or of a UTF-8 string. */ +export function toBase64Url(input: Uint8Array | string): string { + const bytes = typeof input === "string" ? enc.encode(input) : input; + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** Bytes of a base64url string; null when it is not valid base64url. */ +export function fromBase64Url(value: string): Uint8Array | null { + if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) return null; + const b64 = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4); + try { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } catch { + return null; + } +} + +export const utf8 = { + encode: (s: string) => enc.encode(s), + decode: (b: Uint8Array) => dec.decode(b), +}; + +/** Cryptographically random bytes. */ +export function randomBytes(n: number): Uint8Array { + const out = new Uint8Array(n); + webCrypto().getRandomValues(out); + return out; +} + +/** A random id: `bytes` random bytes as base64url (16 bytes → 22 characters, 128 bits). */ +export function randomId(bytes = 16): string { + return toBase64Url(randomBytes(bytes)); +} + +/** SHA-256 as lowercase hex (async, Web Crypto). */ +export async function sha256Hex(input: string | Uint8Array): Promise<string> { + const data = typeof input === "string" ? enc.encode(input) : input; + const digest = new Uint8Array(await webCrypto().subtle.digest("SHA-256", data as BufferSource)); + let hex = ""; + for (const b of digest) hex += b.toString(16).padStart(2, "0"); + return hex; +} + +/** + * Compares two strings in time that depends only on their lengths. Use it for hashes and ids + * of a fixed length; a different length returns false at once. + */ +export function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +/** JSON with object keys sorted at every level, so equal values always serialise identically. */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const obj = value as Record<string, unknown>; + const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`; +} diff --git a/src/shared/signals.ts b/src/shared/signals.ts new file mode 100644 index 0000000..bb7d3fb --- /dev/null +++ b/src/shared/signals.ts @@ -0,0 +1,59 @@ +import { FINGERPRINT_PATTERN, ID_PATTERN, SIGNAL_LIMITS, SIGNALS_HEADER } from "./constants"; +import { fromBase64Url, toBase64Url, utf8 } from "./crypto"; +import type { DeviceInfo, DeviceSignals } from "./types"; + +/** Header value for `DeviceSignals`: base64url of their JSON. */ +export function encodeSignals(signals: DeviceSignals): string { + return toBase64Url(JSON.stringify(signals)); +} + +/** `{ "x-mgpl-signals": encodeSignals(signals) }`, ready to merge into request headers. */ +export function signalHeaders(signals: DeviceSignals): Record<string, string> { + return { [SIGNALS_HEADER]: encodeSignals(signals) }; +} + +// printable ASCII and common Unicode letters; no control characters, no angle brackets +const SAFE_TEXT = /^[^\u0000-\u001f\u007f<>]*$/; +const text = (v: unknown): string | null => (typeof v === "string" && v.length <= SIGNAL_LIMITS.field && SAFE_TEXT.test(v) ? v : null); + +/** Checks an untrusted value and returns clean `DeviceInfo`, or null if any field is wrong. */ +export function parseDeviceInfo(v: unknown): DeviceInfo | null { + if (!v || typeof v !== "object") return null; + const d = v as Record<string, unknown>; + const os = text(d.os), browser = text(d.browser), browserVersion = text(d.browserVersion); + const language = text(d.language), timezone = text(d.timezone), screen = text(d.screen); + if (os === null || browser === null || browserVersion === null || language === null || timezone === null || screen === null) return null; + if (typeof d.mobile !== "boolean") return null; + return { os, browser, browserVersion, mobile: d.mobile, language, timezone, screen }; +} + +/** Checks an untrusted object and returns clean `DeviceSignals` (unknown keys dropped), or null. */ +export function parseSignals(v: unknown): DeviceSignals | null { + if (!v || typeof v !== "object" || Array.isArray(v)) return null; + const s = v as Record<string, unknown>; + if (s.v !== 1) return null; + if (typeof s.deviceId !== "string" || !ID_PATTERN.test(s.deviceId)) return null; + if (typeof s.tabId !== "string" || !ID_PATTERN.test(s.tabId)) return null; + if (typeof s.fingerprint !== "string" || !FINGERPRINT_PATTERN.test(s.fingerprint)) return null; + const device = parseDeviceInfo(s.device); + if (!device) return null; + let app: string | undefined; + if (s.app !== undefined) { + const a = text(s.app); + if (a === null) return null; + app = a; + } + return { v: 1, deviceId: s.deviceId, tabId: s.tabId, fingerprint: s.fingerprint, device, ...(app !== undefined ? { app } : {}) }; +} + +/** Decodes and validates a header value; null when missing, too long or malformed. Never throws. */ +export function decodeSignals(value: string | null | undefined): DeviceSignals | null { + if (typeof value !== "string" || !value || value.length > SIGNAL_LIMITS.header) return null; + const bytes = fromBase64Url(value.trim()); + if (!bytes) return null; + try { + return parseSignals(JSON.parse(utf8.decode(bytes))); + } catch { + return null; + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..88187df --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,34 @@ +/** Coarse facts about the device and browser. No canvas, audio, WebGL or font probing. */ +export interface DeviceInfo { + /** "Windows" | "macOS" | "iOS" | "Android" | "ChromeOS" | "Linux" | "Other" */ + os: string; + /** "Chrome" | "Edge" | "Firefox" | "Safari" | "Opera" | "Samsung Internet" | "Other" */ + browser: string; + /** Major version only, e.g. "131"; "" when unknown. */ + browserVersion: string; + mobile: boolean; + /** BCP 47 language tag, e.g. "en-IN". */ + language: string; + /** IANA time zone, e.g. "Asia/Kolkata". */ + timezone: string; + /** "1920x1080" (larger side first, so rotating a phone does not change it); "" when unknown. */ + screen: string; +} + +/** + * The security signals a client sends with login and refresh (and may send with every request). + * Collected by `@mgpl-diagnostics/security-kit/browser`, read by `@mgpl-diagnostics/security-kit/server`. + */ +export interface DeviceSignals { + /** Signal format version. */ + v: 1; + /** Random id kept in the browser's persistent storage: one per browser profile. */ + deviceId: string; + /** Random id kept in session storage: one per tab. */ + tabId: string; + /** SHA-256 (hex, 32 chars) of the coarse device attributes; changes when the device's setup changes. */ + fingerprint: string; + device: DeviceInfo; + /** The app's own version, when it sets one. */ + app?: string; +} diff --git a/tsconfig.browser.json b/tsconfig.browser.json new file mode 100644 index 0000000..6b06b88 --- /dev/null +++ b/tsconfig.browser.json @@ -0,0 +1,6 @@ +{ + // The shared and browser parts must not use Node.js APIs: type-checked here without @types/node. + "extends": "./tsconfig.json", + "compilerOptions": { "types": [] }, + "include": ["src/index.ts", "src/shared", "src/browser"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8bbbbe0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["es2022", "dom"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src", "scripts"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..f81eb55 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { index: "src/index.ts", browser: "src/browser/index.ts", server: "src/server/index.ts" }, + format: ["esm"], + dts: true, + target: "es2022", + clean: true, + splitting: true, + external: [/^node:/], +});