dbsc-toolkit 2.9.7 → 2.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +128 -82
  2. package/package.json +4 -2
package/README.md CHANGED
@@ -5,24 +5,24 @@
5
5
  [![License](https://img.shields.io/npm/l/dbsc-toolkit.svg)](./LICENSE)
6
6
  [![Node](https://img.shields.io/node/v/dbsc-toolkit.svg)](https://nodejs.org)
7
7
 
8
- > **Stop stolen session cookies from being replayed on another device.**
8
+ > Stop stolen session cookies from being replayed on another device.
9
9
  >
10
- > `dbsc-toolkit` implements W3C [Device Bound Session Credentials](https://www.w3.org/TR/dbsc/) on the server, and ships a Web Crypto polyfill so the same protection works on browsers that don't speak native DBSC yet. Native DBSC on Chromium 146+ (TPM / Secure Enclave); the polyfill on every other browser. **One server, every browser, no user prompts.**
10
+ > `dbsc-toolkit` is a Node.js implementation of W3C [Device Bound Session Credentials](https://www.w3.org/TR/dbsc/) with a Web Crypto polyfill for browsers that don't speak the protocol natively yet. Native DBSC on Chromium 146+ (TPM on Windows, Secure Enclave on macOS); the polyfill covers Firefox, Safari, mobile, and older Chromium.
11
11
  >
12
- > Works with **Express · Fastify · Hono · Next.js · Redis · PostgreSQL**.
12
+ > Adapters: Express, Fastify, Hono, Next.js. Storage: in-memory, Redis, Postgres.
13
13
 
14
14
  ```ts
15
- // In your login route — one line to bind the session:
15
+ // Bind the session inside your existing login route:
16
16
  app.post("/login", async (req, res) => {
17
17
  await dbsc.bind(res, sessionId, { userId });
18
18
  res.json({ ok: true });
19
19
  });
20
20
 
21
- // On any sensitive route — one guard:
21
+ // Guard sensitive routes:
22
22
  app.post("/payment", express.raw({ type: "*/*" }), requireProof(), paymentHandler);
23
23
  ```
24
24
 
25
- That's it. The rest — key generation, protocol routes, cookie management, polyfill — is handled by the library.
25
+ Key generation, the protocol routes, cookie management, and the polyfill all live inside the library.
26
26
 
27
27
  ## Contents
28
28
 
@@ -32,7 +32,7 @@ That's it. The rest — key generation, protocol routes, cookie management, poly
32
32
  - [Why dbsc-toolkit](#why-dbsc-toolkit)
33
33
  - [Comparison](#comparison)
34
34
  - [Why not JWT?](#why-not-just-use-jwt)
35
- - [How it works](#how-it-works-the-30-second-version)
35
+ - [How it works](#how-it-works)
36
36
  - [Who is this for?](#who-is-this-for)
37
37
  - [Adding to an existing app](#adding-to-an-existing-app)
38
38
  - [Protect your routes](#protect-your-routes)
@@ -43,18 +43,20 @@ That's it. The rest — key generation, protocol routes, cookie management, poly
43
43
 
44
44
  ## Why dbsc-toolkit
45
45
 
46
- Native DBSC ships on Chrome 146+ for Windows users with a TPM. That's about a third of internet users. Every other DBSC implementation gives the rest of your users nothing — they remain on plain bearer cookies.
46
+ Native DBSC ships on Chrome 146+ for Windows and macOS users with a hardware key store. That's roughly a third of the desktop traffic most apps see. Most other DBSC implementations focus on native browser support only, which leaves the rest of your users on plain bearer cookies.
47
47
 
48
- This library is among the first DBSC implementations to provide a Web Crypto fallback for browsers that don't support native DBSC. Same wire-level protection against cookie theft, same `requireProof()` guard server-side, key stored non-extractably in IndexedDB instead of a TPM. No biometric prompt, no user interaction, no checkbox.
48
+ This library ships a Web Crypto polyfill alongside native DBSC, so non-Chromium browsers get the same wire-level protection against cookie theft and the same `requireProof()` guard on the server. The polyfill key is stored non-extractably in IndexedDB rather than a TPM — there is no biometric prompt or any user interaction.
49
49
 
50
- - ✅ **Native W3C DBSC** on Chromium 146+ (TPM on Windows, Secure Enclave on macOS)
51
- - ✅ **Web Crypto polyfill** on Firefox, Safari, mobile, older Chromium — same protection, IndexedDB key
52
- - ✅ **Express, Fastify, Hono, Next.js** adapters — all four ship in one package
53
- - ✅ **Memory, Redis, Postgres** storage — pick one, swap with one line
54
- - ✅ **`requireProof()`** per-request signature guard — works on every browser, body-hash defended against MITM
55
- - ✅ **Replay cache** (v2.8+) — rejects captured-proof replay outside the timestamp window
56
- - ✅ **Multi-subdomain** (v2.9+) — `cookieScope: "site"` for `app.example.com` + `api.example.com`
57
- - ✅ **Apache 2.0** — read every line before you ship it
50
+ What you actually get:
51
+
52
+ - Native W3C DBSC on Chromium 146+ (TPM on Windows, Secure Enclave on macOS).
53
+ - A Web Crypto polyfill that brings the same protection to Firefox, Safari, mobile and older Chromium. Non-extractable IndexedDB key.
54
+ - Adapters for Express, Fastify, Hono and Next.js — all four in one package.
55
+ - Storage adapters for memory, Redis and Postgres. Swap by changing one import.
56
+ - `requireProof()` — a per-request signature guard that works on every browser and defends against MITM body substitution via the signed body hash.
57
+ - Replay cache (since v2.8) that rejects a captured-proof replay inside the timestamp window. Opt-in; you wire `RedisReplayCache` when you want it.
58
+ - Multi-subdomain binding (since v2.9) via `cookieScope: "site"` for apps split across `app.example.com` + `api.example.com`.
59
+ - Apache 2.0.
58
60
 
59
61
  ## Install
60
62
 
@@ -62,16 +64,16 @@ This library is among the first DBSC implementations to provide a Web Crypto fal
62
64
  npm install dbsc-toolkit
63
65
  ```
64
66
 
65
- Optional peer deps — install only the framework + storage you actually use:
67
+ Optional peer dependencies — install only what you actually use:
66
68
 
67
69
  ```sh
68
- npm install express ioredis # Express + Redis
70
+ npm install express ioredis # Express + Redis
69
71
  npm install fastify @fastify/cookie pg # Fastify + Postgres
70
72
  ```
71
73
 
72
74
  ## Quick start
73
75
 
74
- Copy-paste runnable. `createDbsc()` takes your config once; `install()` mounts everything — the protocol routes, the bound-route JSON parser, the `/dbsc-client` SDK, and `trust proxy`. No `cookie-parser`, no manual static mount.
76
+ `createDbsc()` takes your config once. `install()` mounts the protocol routes, the bound-route JSON parser, the `/dbsc-client` SDK, and sets `trust proxy`.
75
77
 
76
78
  ```ts
77
79
  import express from "express";
@@ -83,7 +85,7 @@ const app = express();
83
85
  app.use(express.json()); // for your own routes' JSON bodies
84
86
 
85
87
  const dbsc = createDbsc({ storage: new MemoryStorage() }); // swap for Redis/Postgres in prod
86
- dbsc.install(app); // mounts /dbsc/*, /dbsc-bound/*, the SDK, trust proxy
88
+ dbsc.install(app);
87
89
 
88
90
  app.post("/login", async (req, res) => {
89
91
  await dbsc.bind(res, randomUUID(), { userId: req.body.username });
@@ -94,7 +96,7 @@ app.get("/me", (_req, res) => res.json(res.locals.dbsc));
94
96
  app.listen(3000);
95
97
  ```
96
98
 
97
- In your HTML, load the polyfill once so Firefox / Safari / older Chromium reach `tier: "bound"`:
99
+ Load the polyfill in your HTML so Firefox / Safari / older Chromium can reach `tier: "bound"`:
98
100
 
99
101
  ```html
100
102
  <script type="module">
@@ -103,22 +105,43 @@ In your HTML, load the polyfill once so Firefox / Safari / older Chromium reach
103
105
  </script>
104
106
  ```
105
107
 
106
- Without the script tag those browsers stay on `tier: "none"`. Native Chromium 146+ does not need the script — it negotiates the protocol on its own from the headers `dbsc()` sets.
108
+ Without the script those browsers stay on `tier: "none"`. Chromium 146+ doesn't need it — it negotiates the protocol on its own from the headers `dbsc()` sets.
107
109
 
108
110
  ### Common failure modes
109
111
 
110
- - **`tier` always reads `"none"` on Chromium 146+?** Running on plain HTTP (DBSC needs HTTPS), or `dbsc.install(app)` was never called. `install()` already sets `trust proxy` and parses cookies, so the old "middleware order" class of bug is gone.
111
- - **Chrome loops registration?** Storage was wiped — switch off `MemoryStorage` to Redis or Postgres before deploying anywhere that ever restarts.
112
- - **Tier flips back to `"none"` right after login?** The race between `/login` returning and the browser running `POST /dbsc/registration`. Poll `/me` for ~1 s after login or await the bound-SDK outcome promise. The demo wires both — see [examples/express/src/server.js](./examples/express/src/server.js).
113
- - **Firefox / Safari still on `"none"`?** Forgot the `<script type="module">` tag above. `install()` serves the SDK at `/dbsc-client` for you; you still load it on the page.
112
+ - **`tier` always reads `"none"` on Chromium 146+** — running on plain HTTP (DBSC needs HTTPS), or `dbsc.install(app)` was never called. `install()` sets `trust proxy` and parses cookies, so the old "middleware order" class of bug is gone.
113
+ - **Chrome loops registration** — storage got wiped. Move off `MemoryStorage` to Redis or Postgres before deploying anywhere that restarts. I learned this the hard way on Render free tier.
114
+ - **Tier flips back to `"none"` right after login** — the race between `/login` returning and the browser running `POST /dbsc/registration`. Poll `/me` for ~1 s after login, or await the bound-SDK outcome promise. The demo wires both — see [examples/express/src/server.js](./examples/express/src/server.js).
115
+ - **Firefox / Safari still on `"none"`** — the `<script type="module">` tag is missing. `install()` serves the SDK at `/dbsc-client`; you still load it on the page.
116
+
117
+ Walk-through: [docs/getting-started.md](./docs/getting-started.md).
118
+
119
+ ### Not on Express?
114
120
 
115
- Full walk-through: [docs/getting-started.md](./docs/getting-started.md).
121
+ `createDbsc()` is the Express convenience layer. The protocol itself — native DBSC, the polyfill, per-request proof verification — lives in a framework-agnostic core that takes plain data and a `StorageAdapter`, nothing else. The Express, Fastify, Hono, and Next.js adapters are thin wrappers over it. If you're on a different runtime (or wiring DBSC into your own framework's session layer), import the core handlers directly:
116
122
 
117
- ## Live demo
123
+ ```ts
124
+ import {
125
+ handleRegistration, // POST /dbsc/registration
126
+ handleRefresh, // POST /dbsc/refresh
127
+ handleBoundRegistration, handleBoundRefresh, // the polyfill routes
128
+ verifyBoundProof, // your requireProof() equivalent
129
+ issueChallenge,
130
+ buildRegistrationHeader,
131
+ type StorageAdapter, // the only interface you implement
132
+ } from "dbsc-toolkit";
133
+ ```
118
134
 
119
- <https://dbsc-toolkit.onrender.com/>
135
+ These are the same functions every shipped adapter calls. A complete raw-`http` integration built on them — both protocol surfaces, every header and cookie detail — is in [docs/adapters.md](./docs/adapters.md#writing-your-own-adapter).
120
136
 
121
- Sign up, log in, click **Check session**. Chromium 146+ lands on `tier: "dbsc"` within a second; Firefox/Safari land on `tier: "bound"` within ~3 seconds. The demo uses a 60-second bound-cookie TTL so refresh fires fast — open DevTools Network and watch. Source: [examples/express/](./examples/express/).
137
+ ## Live demos
138
+
139
+ | URL | Stack | Source |
140
+ |---|---|---|
141
+ | <https://dbsc-toolkit.onrender.com/> | Express + raw `dbsc-toolkit` | [examples/express/](./examples/express/) |
142
+ | <https://dbsc-better-auth-demo.onrender.com/> | Hono + Better Auth + `@dbsc-toolkit/better-auth` | [examples/better-auth/](./examples/better-auth/) |
143
+
144
+ Sign up, log in, click **Check session**. Chromium 145+ lands on `tier: "dbsc"` within a second; Firefox/Safari land on `tier: "bound"` within ~3 seconds. Open DevTools Network and watch the binding flow.
122
145
 
123
146
  ## Comparison
124
147
 
@@ -134,43 +157,43 @@ Sign up, log in, click **Check session**. Chromium 146+ lands on `tier: "dbsc"`
134
157
  | Multi-subdomain binding | ✅ (loose) | ✅ (loose) | ❌ (`__Host-` only) | ✅ (v2.9 `cookieScope: "site"`) |
135
158
  | Server runtime | any | any | n/a (browser-side) | Node.js ≥ 20 |
136
159
 
137
- The native-DBSC column refers to a server that uses the spec without a polyfill — what every other DBSC implementation currently offers. The polyfill key lives in IndexedDB (`extractable: false`) rather than a TPM, so it's a notch weaker against malware reading the browser profile, but defeats every remote-theft scenario.
160
+ The native-DBSC column above describes a server that uses the spec without any polyfill. The polyfill key in this library lives in IndexedDB (`extractable: false`) rather than a TPM — a notch weaker against malware reading the browser profile, but it stops every remote-theft scenario the same way native DBSC does.
138
161
 
139
162
  ## "Why not just use JWT?"
140
163
 
141
- JWTs are still bearer tokens. Whoever has the bytes can replay them. Steal a JWT from a log line, an XSS, a leaked proxy header, a misconfigured intermediary — paste it into another machine and the server treats it as the legitimate user, exactly like a stolen cookie.
142
-
143
- DBSC pins the session to a cryptographic key that lives on the user's device (TPM on Windows, Secure Enclave on macOS, or — under the polyfill — a non-extractable IndexedDB key on every other browser). The token alone is no longer sufficient: the browser has to prove possession of the key on every refresh, and on every guarded request via `requireProof()`. The attacker's device has no key, so the replay fails and the session demotes within one cycle.
164
+ JWTs are bearer tokens. If stolen, they can be replayed from another device. DBSC adds proof-of-possession: the browser has to prove on every refresh (and on every `requireProof()`-guarded request) that it still has the private key bound at login. An attacker who got the token alone has nothing to sign with.
144
165
 
145
- DBSC complements your existing auth (passwords, MFA, JWT/session cookies) — it does not replace them. It closes the *replay-after-issue* gap that bearer tokens cannot.
166
+ It complements your existing auth — passwords, MFA, session cookies, JWTs — rather than replacing any of them. The gap it closes is *replay after issue*.
146
167
 
147
- ## How it works (the 30-second version)
168
+ ## How it works
148
169
 
149
170
  ```
150
- Login → Key Registration → Cookie Issued → Challenge → Signature Verification → Session Refresh
171
+ Login → Key Registration → Cookie Issued → Challenge → Signature Verification → Refresh
151
172
  ```
152
173
 
153
- On login the server responds with a `Secure-Session-Registration` header. The browser generates an ECDSA keypair in the TPM (Chrome) or IndexedDB (Firefox/Safari/polyfill), POSTs the public key, and gets back a short-lived session cookie. Every ~10 minutes the browser re-signs a fresh server challenge to renew the cookie. A copy of the cookie pasted into another device has no key — the next refresh fails and `requireProof()` 403s every guarded request immediately.
174
+ On login the server responds with a `Secure-Session-Registration` header. The browser generates an ECDSA keypair in the TPM (native DBSC) or IndexedDB (polyfill), POSTs the public key, and gets a short-lived session cookie. Every ~10 minutes the browser re-signs a fresh server challenge to renew the cookie. A copy of the cookie pasted into another device has no key — the next refresh fails, and any route guarded by `requireProof()` returns 403 immediately.
154
175
 
155
176
  Full wire-format walk-through: [HOW-IT-WORKS.md](./HOW-IT-WORKS.md).
156
177
 
157
178
  ## Who is this for?
158
179
 
159
- **Use `dbsc-toolkit` if:**
180
+ Use `dbsc-toolkit` if:
181
+
160
182
  - You run a Node.js backend.
161
183
  - Your users authenticate with session cookies (or JWT bearer tokens).
162
184
  - Cookie / token theft is in your threat model — XSS, infostealer malware, leaked logs, compromised proxies.
163
- - You want one library that covers Chrome **and** Firefox / Safari today, not "eventually."
185
+ - You want coverage on Chrome and Firefox / Safari now, not eventually.
186
+
187
+ Not the right fit if:
164
188
 
165
- **Not the right fit if:**
166
189
  - You don't issue any persistent client credential (every request re-authenticates from scratch).
167
190
  - You can't deploy HTTPS — DBSC cookies require `Secure`, and the spec rejects insecure origins.
168
- - Your backend is Python / Go / Rust / Java / .NET — no port of this library exists yet; you'd implement against the [W3C spec](https://www.w3.org/TR/dbsc/) directly.
169
- - You need DBSC on iOS / mobile Chrome / ChromeOS *with native key storage* — those platforms don't ship native DBSC. (The polyfill still works there; the key just lives in IndexedDB instead of secure hardware.)
191
+ - Your backend is Python / Go / Rust / Java / .NET — no port exists yet; you'd implement against the [W3C spec](https://www.w3.org/TR/dbsc/) directly.
192
+ - You need DBSC on iOS / mobile Chrome / ChromeOS *with native key storage* — those platforms don't ship native DBSC. The polyfill still works there; the key just lives in IndexedDB instead of secure hardware.
170
193
 
171
194
  ## Adding to an existing app
172
195
 
173
- You don't rewrite login, you don't migrate the session store. DBSC sits alongside your existing session cookie and binds to the same session id. The whole integration for an Express app:
196
+ You don't rewrite login and you don't migrate the session store. DBSC sits alongside your existing session cookie and binds to the same session id.
174
197
 
175
198
  ```ts
176
199
  import { createDbsc, requireProof } from "dbsc-toolkit/express";
@@ -202,40 +225,68 @@ app.post("/logout", async (req, res) => {
202
225
  });
203
226
  ```
204
227
 
205
- `install()` handles `trust proxy`, cookie parsing, the bound-route JSON parser, and the `/dbsc-client` static mount — you don't wire those. You still need `express.json()` for your *own* routes' bodies. `sessionId` is whatever id your session store already issues; DBSC binds to it — no second id-space.
228
+ `install()` handles `trust proxy`, cookie parsing, the bound-route JSON parser, and the `/dbsc-client` static mount. You still need `express.json()` for your *own* routes' bodies. `sessionId` is whatever id your session store already issues — DBSC binds to it, no second id-space.
206
229
 
207
- **No server-side session id** (NextAuth JWT mode, iron-session, Lucia stateless)? Call `dbsc.bind(res, { userId })` with no id — the kit derives a stable one and manages a per-device cookie so each browser of the same user binds independently. Per-system recipes: [docs/integration-recipes.md](./docs/integration-recipes.md).
230
+ **No server-side session id?** (NextAuth JWT mode, iron-session, Lucia stateless) Call `dbsc.bind(res, { userId })` with no id — the kit derives a stable one and manages a per-device cookie so each browser of the same user binds independently. Per-system recipes: [docs/integration-recipes.md](./docs/integration-recipes.md).
208
231
 
209
- **App split across subdomains** (`app.example.com` + `api.example.com`)? Default `__Host-` cookies are origin-locked — keep DBSC on one origin (proxy `/dbsc/*` and `/dbsc-bound/*` through the UI host) for the strongest setting. If a same-origin layout is genuinely not workable, v2.9.0+ exposes `cookieScope: "site"` + `cookieDomain: "example.com"` on `createDbsc({...})`, switching the binding cookies to `__Secure-` with a `Domain` attribute. The validator throws at construction if either is wrong, so misconfiguration is loud, not silent. Trade-off and concrete recipe: [docs/integration-recipes.md#multi-subdomain-apps-cookiescope-site](./docs/integration-recipes.md#multi-subdomain-apps-cookiescope-site).
232
+ **App split across subdomains?** (`app.example.com` + `api.example.com`) Default `__Host-` cookies are origin-locked — proxy `/dbsc/*` and `/dbsc-bound/*` through one origin if you can; that's the strongest setting. If a same-origin layout isn't workable, v2.9.0+ exposes `cookieScope: "site"` + `cookieDomain: "example.com"` on `createDbsc({...})`, switching the binding cookies to `__Secure-` with a `Domain` attribute. The validator throws at construction time if either is wrong — misconfiguration is loud, not silent. Trade-off and concrete recipe: [docs/integration-recipes.md#multi-subdomain-apps-cookiescope-site](./docs/integration-recipes.md#multi-subdomain-apps-cookiescope-site).
210
233
 
211
- **The full step-by-step** — every option and its default, the `autoBind` transparent-rollout variant, the per-route policy table, the migration timeline — is in [docs/integrating-existing-auth.md](./docs/integrating-existing-auth.md).
234
+ Full walk-through with `autoBind`, per-route policy, and the migration timeline: [docs/integrating-existing-auth.md](./docs/integrating-existing-auth.md).
212
235
 
213
- ## Protect your routes
236
+ ### Using Better Auth?
214
237
 
215
- > **First time here?** [docs/usage.md](./docs/usage.md) walks the setup and the guard in order, with concrete code. Five-minute read.
238
+ Install [@dbsc-toolkit/better-auth](./packages/better-auth/) — sessions from every sign-in method (email, OAuth, magic link, passkey) get bound automatically:
216
239
 
217
- After `createDbsc().install()`, every request through the middleware has a `tier` field on the request context. The library does not auto-protect anything — you add **one guard, `requireProof()`**, to each route that matters.
240
+ ```ts
241
+ // auth.ts
242
+ import { betterAuth } from "better-auth"
243
+ import { dbsc } from "@dbsc-toolkit/better-auth"
244
+
245
+ export const auth = betterAuth({
246
+ plugins: [dbsc()],
247
+ })
248
+ ```
249
+
250
+ ```ts
251
+ // server.ts
252
+ import { dbscExpress } from "@dbsc-toolkit/better-auth/express"
253
+
254
+ const dbsc = dbscExpress(auth)
255
+ dbsc.install(app) // mounts /dbsc/*, /dbsc-bound/*, /dbsc-client/*
256
+ app.all("/api/auth/*splat", toNodeHandler(auth))
257
+ app.get("/profile", dbsc.requireProof(), profileHandler)
258
+ ```
259
+
260
+ ```html
261
+ <script src="/dbsc-client/init.js" type="module"></script>
262
+ ```
263
+
264
+ Live demo: <https://dbsc-better-auth-demo.onrender.com/>. Full docs in the [package README](./packages/better-auth/README.md).
265
+
266
+ ## Protect your routes
267
+
268
+ After `createDbsc().install()`, every request through the middleware has a `tier` field on the request context. The library does not auto-protect anything — you add `requireProof()` to each route that matters.
218
269
 
219
270
  | Your route does… | Use this guard | What it stops |
220
271
  |---|---|---|
221
272
  | Public / read-only (feed, search, public profile) | Nothing | n/a — no auth gate at all |
222
- | Anything authenticated (post, comment, upvote, settings, payment, admin) | `requireProof()` (server) + `wrapFetch()` (client) | A stolen cookie cannot be replayed from another device, cannot ride along during the freshness window, and an MITM cannot substitute a POST body |
273
+ | Anything authenticated (post, comment, upvote, settings, payment, admin) | `requireProof()` (server) + `wrapFetch()` (client) | A stolen cookie can't be replayed from another device, can't ride along during the freshness window, and an MITM can't substitute a POST body |
223
274
 
224
- There is deliberately **no tier-level argument**. A `dbsc`-only gate would lock out every Firefox/Safari user; a `bound`-only check (tier without a per-request proof) is not actually secure. `requireProof()` is the one honest answer — it requires a bound device + a per-request proof and works on every browser. As of v2.7 Chromium sessions also register a polyfill key alongside the TPM key, so the per-request signature is enforced on every tier (not just Firefox/Safari). `wrapFetch` signs the request body by default in v2.8+; a POST guarded route mounts `express.raw()` in front. Apps with many guarded routes can use `installFetchInterceptor({ pathPrefixes: ["/api/secure/"] })` to wrap once at boot instead of per-call.
275
+ `requireProof()` requires a bound device plus a per-request proof, and works on every browser. As of v2.7, Chromium sessions co-register a polyfill key alongside the TPM key so the per-request signature is enforced uniformly across tiers. `wrapFetch` signs the request body by default since v2.8; POST routes mount `express.raw()` in front. Apps with many guarded routes can swap to `installFetchInterceptor({ pathPrefixes: ["/api/secure/"] })` and wrap once at boot.
225
276
 
226
- For apps with a stricter threat model (active MITM, log-spillage exposure), v2.8 also adds an optional **replay cache** that rejects a second arrival of the same proof bytes — pass `replayCache: new RedisReplayCache(redis)` to `createDbsc`. See [docs/per-request-signing.md](./docs/per-request-signing.md).
277
+ For apps facing active MITM or log-spillage exposure, v2.8 also adds an optional **replay cache** that rejects a second arrival of the same proof bytes — pass `replayCache: new RedisReplayCache(redis)` to `createDbsc`. See [docs/per-request-signing.md](./docs/per-request-signing.md).
227
278
 
228
- The full threat boundary, the per-framework wiring (Fastify / Hono / Next.js), and the migration timeline for an existing app are in [docs/integrating-existing-auth.md](./docs/integrating-existing-auth.md) and [docs/per-request-signing.md](./docs/per-request-signing.md).
279
+ Full threat boundary, per-framework wiring (Fastify / Hono / Next.js), and the migration timeline: [docs/integrating-existing-auth.md](./docs/integrating-existing-auth.md) and [docs/per-request-signing.md](./docs/per-request-signing.md).
229
280
 
230
281
  ## Production checklist
231
282
 
232
- - [ ] HTTPS enabled — DBSC requires `Secure` cookies; plain HTTP locks every user to `tier: "none"`
233
- - [ ] Redis or PostgreSQL storage — `MemoryStorage` is lost on restart; bound browsers loop registration
234
- - [ ] `requireProof()` on every authenticated route — read-only public routes need no guard
235
- - [ ] `wrapFetch({ signBody: true })` on the client side for those routes (already the default in v2.8+)
236
- - [ ] Replay cache enabled — `replayCache: new RedisReplayCache(redis)` in `createDbsc({...})`
237
- - [ ] `trust proxy` already set — `install()` sets it, but verify if you have your own app config overriding it
238
- - [ ] Polyfill script loaded — the `<script type="module">` tag; without it Firefox/Safari stay on `tier: "none"`
283
+ - [ ] HTTPS enabled — DBSC requires `Secure` cookies; plain HTTP locks every user to `tier: "none"`.
284
+ - [ ] Redis or PostgreSQL storage — `MemoryStorage` is lost on restart; bound browsers loop registration.
285
+ - [ ] `requireProof()` on every authenticated route — read-only public routes need no guard.
286
+ - [ ] `wrapFetch({ signBody: true })` on the client side for those routes (default since v2.8).
287
+ - [ ] Replay cache enabled when relevant — `replayCache: new RedisReplayCache(redis)` in `createDbsc({...})`.
288
+ - [ ] `trust proxy` set — `install()` sets it, but verify nothing in your own app config overrides it.
289
+ - [ ] Polyfill script loaded — the `<script type="module">` tag; without it Firefox/Safari stay on `tier: "none"`.
239
290
 
240
291
  ## Subpath imports
241
292
 
@@ -249,11 +300,11 @@ The full threat boundary, the per-framework wiring (Fastify / Hono / Next.js), a
249
300
  | `dbsc-toolkit/client` | Browser SDK with `initBoundDbsc()` for the polyfill |
250
301
  | `dbsc-toolkit/storage/{memory,redis,postgres}` | Storage adapters |
251
302
 
252
- Tree-shaking eliminates anything you don't import. Using Koa, Hapi, raw `http`, Bun, or Deno? Call core directly. See [docs/adapters.md](./docs/adapters.md).
303
+ Tree-shaking eliminates anything you don't import. Using Koa, Hapi, raw `http`, Bun, or Deno? Call core directly — see [docs/adapters.md](./docs/adapters.md).
253
304
 
254
305
  ## Protection tiers
255
306
 
256
- `tier` is the **state** of a session — what kind of binding the browser achieved. It is *not* a knob you gate on directly:
307
+ `tier` is the state of a session — what kind of binding the browser achieved. Don't gate on it directly; gate with `requireProof()` instead and read `tier` only for display.
257
308
 
258
309
  | Tier | Mechanism | Protects against |
259
310
  |------|-----------|------------------|
@@ -261,37 +312,32 @@ Tree-shaking eliminates anything you don't import. Using Koa, Hapi, raw `http`,
261
312
  | `bound` | Web Crypto polyfill, non-extractable ECDSA P-256 key in IndexedDB | Cookie theft. Does not defeat infostealer malware on the user's machine. |
262
313
  | `none` | No active / fresh binding | Nothing the cookie itself doesn't already do |
263
314
 
264
- **How to gate routes — the whole decision tree:**
265
-
266
- - **Public / read-only route?** → no guard.
267
- - **Anything authenticated?** → `requireProof()`. That's it.
268
-
269
- There is no third option, and **never gate a route on `tier === "dbsc"`** — that locks out every Firefox and Safari user (they can only reach `tier: "bound"`), and `requireProof()` already gives those browsers the same per-request guarantee via a signed proof. `requireProof()` is the one guard; it works on every browser. Read `res.locals.dbsc.tier` only if you want to *display* binding state in the UI — not to build a gate. Full guidance in [docs/security/best-practices.md](./docs/security/best-practices.md).
315
+ Why not gate routes on `tier === "dbsc"`? Because every Firefox and Safari user can only reach `tier: "bound"`, and `requireProof()` already gives those browsers the same per-request guarantee via a signed proof. The one exception is routes whose threat model specifically includes on-device infostealer malware — there, hardware-backed key isolation actually matters, and you accept the trade-off of locking non-Chromium browsers out. More in [docs/security/best-practices.md](./docs/security/best-practices.md).
270
316
 
271
317
  ## Security model
272
318
 
273
- `dbsc-toolkit` protects against:
319
+ What this protects against:
274
320
 
275
- - Stolen session cookies replayed from another device
276
- - Stolen bearer tokens (same category — bearer == portable)
277
- - Cookie exfiltration via XSS, network capture, log leakage, or proxy leakage
321
+ - Stolen session cookies replayed from another device.
322
+ - Stolen bearer tokens (same category — bearer == portable).
323
+ - Cookie exfiltration via XSS, network capture, log leakage, or proxy leakage.
278
324
 
279
- The Web Crypto polyfill (`tier: "bound"`) does **not** protect against:
325
+ What the Web Crypto polyfill (`tier: "bound"`) does not protect against:
280
326
 
281
- - Malware already running on the user's machine with access to the browser profile
282
- - Browser or OS compromise
327
+ - Malware running on the user's machine with access to the browser profile.
328
+ - Browser or OS compromise.
283
329
 
284
- Native DBSC (`tier: "dbsc"`) provides stronger protection against local malware because the private key lives inside the TPM or Secure Enclave — software cannot extract it even with admin access. The polyfill key is non-extractable to JavaScript, but the encrypted blob lives on disk and a sufficiently privileged attacker can reach it.
330
+ Native DBSC (`tier: "dbsc"`) is stronger against local malware because the private key lives inside the TPM or Secure Enclave — software can't extract it even with admin access. The polyfill key is non-extractable from JavaScript, but the encrypted blob lives on disk and a privileged on-device attacker can reach it.
285
331
 
286
- Neither tier is a substitute for HTTPS, input validation, strong passwords, or MFA.
332
+ None of this replaces HTTPS, input validation, strong passwords, or MFA.
287
333
 
288
334
  ## Going deeper
289
335
 
290
336
  - **Concepts and protocol:** [HOW-IT-WORKS.md](./HOW-IT-WORKS.md)
291
337
  - **Bound polyfill wire protocol:** [docs/bound-polyfill.md](./docs/bound-polyfill.md)
292
- - **Per-request signing (the v2.7 dual-key story + the v2.8 replay cache):** [docs/per-request-signing.md](./docs/per-request-signing.md)
338
+ - **Per-request signing (v2.7 dual-key + v2.8 replay cache):** [docs/per-request-signing.md](./docs/per-request-signing.md)
293
339
  - **API reference:** [docs/api-reference.md](./docs/api-reference.md)
294
- - **Adapters (Express / Fastify / Hono / Next.js + write your own):** [docs/adapters.md](./docs/adapters.md)
340
+ - **Adapters (Express / Fastify / Hono / Next.js + writing your own):** [docs/adapters.md](./docs/adapters.md)
295
341
  - **Storage (memory / Redis / Postgres):** [docs/storage.md](./docs/storage.md)
296
342
  - **Telemetry hooks:** [docs/telemetry.md](./docs/telemetry.md)
297
343
  - **Deployment (Render / Fly / Vercel / Cloudflare / nginx):** [docs/deployment.md](./docs/deployment.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbsc-toolkit",
3
- "version": "2.9.7",
3
+ "version": "2.9.9",
4
4
  "description": "Server-side Device Bound Session Credentials (DBSC) for Node.js plus a Web Crypto polyfill for Firefox, Safari, and older Chromium. Cookie-theft protection on every modern browser.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -93,6 +93,7 @@
93
93
  },
94
94
  "devDependencies": {
95
95
  "@fastify/cookie": "^11.0.2",
96
+ "fastify-plugin": "^5.0.1",
96
97
  "@types/express": "^5.0.6",
97
98
  "@types/node": "^20.0.0",
98
99
  "@types/pg": "^8.0.0",
@@ -136,5 +137,6 @@
136
137
  },
137
138
  "engines": {
138
139
  "node": ">=20.0.0"
139
- }
140
+ },
141
+ "packageManager": "pnpm@9.0.0"
140
142
  }