Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Every change to this repo needs a review from a code owner (see CONTRIBUTING in mgpl-diagnostics/.github).
* @BackStacked
21 changes: 21 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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 }}
54 changes: 54 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
143 changes: 143 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<div align="center">

# 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)

</div>

```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).
Loading
Loading