@pawmaskrelease/psecure 1.0.0 → 1.1.0

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 (74) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +310 -41
  3. package/bin/psecure.mjs +13 -0
  4. package/dist/adapters/desktop.js +1 -1
  5. package/dist/adapters/index.js +1 -1
  6. package/dist/adapters/mobile-bridge.js +1 -1
  7. package/dist/adapters/react-native.js +1 -1
  8. package/dist/adapters/web-shield.js +1 -1
  9. package/dist/adapters/web-vault.js +1 -1
  10. package/dist/cli/doctor.d.ts +31 -0
  11. package/dist/cli/doctor.js +108 -0
  12. package/dist/client/index.js +1 -1
  13. package/dist/client/psecure-client.js +83 -82
  14. package/dist/crypto/block-ciphers.js +1 -1
  15. package/dist/crypto/chacha-cfb.js +1 -1
  16. package/dist/crypto/index.js +1 -1
  17. package/dist/crypto/mba.js +1 -1
  18. package/dist/crypto/primitives.js +1 -1
  19. package/dist/crypto/threefish.js +1 -1
  20. package/dist/crypto/wasm-coprocessor.js +1 -1
  21. package/dist/crypto/whitebox-aes.js +1 -1
  22. package/dist/dist-manifest.json +100 -55
  23. package/dist/errors.d.ts +14 -0
  24. package/dist/errors.js +53 -0
  25. package/dist/fetch/index.d.ts +40 -0
  26. package/dist/fetch/index.js +93 -0
  27. package/dist/fetch/load-client.d.ts +1 -0
  28. package/dist/fetch/load-client.js +3 -0
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.js +4 -1
  31. package/dist/protocol/canonical.js +1 -1
  32. package/dist/protocol/codecs.js +1 -1
  33. package/dist/protocol/constants.js +1 -1
  34. package/dist/protocol/dvp-probes.js +1 -1
  35. package/dist/protocol/ed25519.js +1 -1
  36. package/dist/protocol/index.js +1 -1
  37. package/dist/protocol/types.d.ts +3 -1
  38. package/dist/protocol/types.js +1 -1
  39. package/dist/protocol/wire.js +1 -1
  40. package/dist/rasp/auto-collector.js +1 -1
  41. package/dist/rasp/behavior-stream.js +1 -1
  42. package/dist/rasp/dvp-consistency.js +1 -1
  43. package/dist/rasp/hardware-binding.js +1 -1
  44. package/dist/rasp/index.js +1 -1
  45. package/dist/rasp/klepto-seal.js +1 -1
  46. package/dist/rasp/mobile-native-probes.js +1 -1
  47. package/dist/rasp/probes.js +1 -1
  48. package/dist/rasp/web-integrity-probes.js +1 -1
  49. package/dist/server/authority-types.d.ts +2 -0
  50. package/dist/server/authority-types.js +1 -1
  51. package/dist/server/edge.d.ts +20 -0
  52. package/dist/server/edge.js +54 -0
  53. package/dist/server/express.d.ts +40 -0
  54. package/dist/server/express.js +65 -0
  55. package/dist/server/fastify.d.ts +35 -0
  56. package/dist/server/fastify.js +45 -0
  57. package/dist/server/guard.d.ts +129 -0
  58. package/dist/server/guard.js +121 -0
  59. package/dist/server/index.d.ts +4 -0
  60. package/dist/server/index.js +5 -1
  61. package/dist/server/middleware.js +1 -1
  62. package/dist/server/remote-authority-client.js +27 -27
  63. package/dist/server/verdicts.js +6 -6
  64. package/dist/version.d.ts +1 -0
  65. package/dist/version.js +3 -0
  66. package/dist/vm/dynamic-ob.js +1 -1
  67. package/dist/vm/env-key.js +1 -1
  68. package/dist/vm/iabc-isa.js +1 -1
  69. package/dist/vm/index.js +1 -1
  70. package/dist/vm/psecure-vm.js +1 -1
  71. package/dist/web/psecure-polymorphic-loader.obf.js +1 -1
  72. package/dist/web/psecure-vm-kernel.js +1 -1
  73. package/dist/web/psecure-web-shield.bundle.js +1 -1
  74. package/package.json +27 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog: `@pawmaskrelease/psecure`
2
+
3
+ ## 1.1.0
4
+
5
+ Integrations in under an hour: framework adapters, a sealing `fetch`, actionable errors and a `doctor` CLI.
6
+ Backward compatible with 1.0.0: nothing was removed or changed in meaning, and every 1.0.0 import keeps working.
7
+
8
+ ### Added
9
+
10
+ - **Server adapters** on one guard core (`createPSecureGuard`), configured from `PSECURE_*` environment variables:
11
+ - `@pawmaskrelease/psecure/fastify`: `psecureFastify` plugin. It captures the raw body itself, sets `request.psecure`,
12
+ and supports route config `{ psecure: true | false }`.
13
+ - `@pawmaskrelease/psecure/express`: `psecure()` middleware and `psecureRawBody` (for `express.json({ verify })`);
14
+ sets `req.psecure`.
15
+ - `@pawmaskrelease/psecure/edge`: `withPSecure(handler)` and `createPSecureEdge()` for Next.js, Hono, Cloudflare
16
+ Workers, Bun and Deno.
17
+ - `PSECURE_REQUEST_HEADERS` / `PSECURE_EXPOSED_HEADERS` for CORS, `getPSecureDecision(request)`, `onDecision` hook,
18
+ `createPSecureAuditThrottle`, pino-compatible logging that never contains the key, envelope, lease or body.
19
+ - Decision table: Monitor never blocks. Block refuses denied and malformed requests everywhere, and fails closed
20
+ (missing envelope, authority down) only on `protect` routes (default: all guarded routes). A lease reissue is
21
+ answered `401` + `x-psecure-reissued-lease`; any other refusal is `403 { error: 'PSECURE_REJECTED' }`.
22
+ - **The console Monitor/Block switch drives enforcement.** The default mode `auto` follows the app's mode, which the
23
+ authority (>= 1.1) reports on every verdict as `appMode`. The last mode seen is cached for 10 minutes when a request
24
+ carries no verdict; a cold start behaves as Monitor. `PSECURE_MODE=monitor | enforce | off` overrides the switch.
25
+ `PSecureVerificationVerdict.appMode` is typed.
26
+ - **App pinning** (`appKey` / `PSECURE_APP_KEY`, recommended): the guard sends it as `expectedAppKey`. The
27
+ authority then denies a lease of any other application, same organization included, with the new reason
28
+ `app_mismatch`, and the guard only takes a console mode reported for that app (`verdict.appKey`). This closes the
29
+ downgrade where a lease of a Monitor app is replayed against a Block app's backend. In `auto` without it, the guard
30
+ logs a one-time `APP_KEY_NOT_PINNED` warning, and `doctor` warns.
31
+ `PSecureRequestVerificationContext.expectedAppKey`, `PSecureHttpVerifyInput.expectedAppKey`, `verdict.appKey` and
32
+ the `app_mismatch` reason are typed and pass through `PSecureRemoteAuthorityClient`.
33
+ - **`createPSecureFetch`** (`@pawmaskrelease/psecure/fetch`, also from the root): a drop-in sealing `fetch`. It falls
34
+ back gracefully to plain `fetch`, filters origins, never sends a POST twice, provides `setAccount()`, refreshes the
35
+ lease in the background, defaults to `telemetry: 'minimal'` and lazy-loads the client core.
36
+ - **`PSecureConfigError`** with a stable `code`, an English `hint` and a `docsUrl`, for common misconfigurations
37
+ (public key missing, invalid or mismatched; keys swapped; non-https authority; CSP or unreachable authority; origins;
38
+ clock skew; raw body; server key missing or rejected).
39
+ - **`npx @pawmaskrelease/psecure doctor`**: checks that the authority is reachable, the pinned key, the clock, CORS,
40
+ a test lease with the app key and the server key (from `PSECURE_SERVER_KEY` only, never printed). Optionally checks
41
+ your API's CORS.
42
+ - `PSECURE_SDK_VERSION`.
43
+
44
+ ### Changed
45
+
46
+ - The `PSecureClient.connect` lease failure error now carries `status` and the authority's SDK `code`
47
+ (`INVALID_APP_KEY`, `CROSS_TENANT_APP_KEY`, …), and names the code in its message.
48
+ - The `@pawmaskrelease/psecure/server` barrel also exports the guard and the Express and Edge adapters (the Fastify
49
+ plugin uses `node:stream` and has its own subpath, so `/server` stays edge-safe).
package/README.md CHANGED
@@ -15,13 +15,97 @@ before it reaches your business logic.
15
15
  > shipped in the public npm client package. `@pawmaskrelease/psecure/server` is backend-only, is
16
16
  > not exported from the root barrel, and must never be bundled into browser code.
17
17
 
18
- - Full docs: **https://pawmask.com/docs/psecure**
18
+ - Full docs: **https://psecure.pawmask.com/docs**
19
19
  - Proprietary software — use requires a valid Paw Mask PSecure subscription or sandbox
20
20
  agreement. See [LICENSE](./LICENSE).
21
21
 
22
22
  > 🇻🇳 **Lưu ý:** Đây là SDK phía client. Việc xác minh (Verify) chạy trên dịch vụ
23
23
  > được Paw Mask lưu trữ tại `https://psecure-api.pawmask.com`; bạn cần giấy phép PSecure còn
24
- > hiệu lực để sử dụng. Xem tài liệu tại https://pawmask.com/docs/psecure.
24
+ > hiệu lực để sử dụng. Xem tài liệu tại https://psecure.pawmask.com/vi/docs.
25
+
26
+ ## Quick start in 5 minutes
27
+
28
+ You need three values from the PSecure console (the app's **SDK** tab has a *Copy configuration* panel that
29
+ prefills every snippet below):
30
+
31
+ | Value | Where | Secret? |
32
+ | --- | --- | --- |
33
+ | App key `psec_app_…` | Applications → your app | no (ships in the client) |
34
+ | Authority public key (64 hex) | Keys → Lease signing keypair | no (ships in the client) |
35
+ | API token `psec_pat_…` | Keys → API tokens → Issue (shown **once**) | **yes**: backend env only |
36
+
37
+ **1. Install** (Node.js >= 20.16 or a modern browser; zero runtime dependencies)
38
+
39
+ ```bash
40
+ npm install @pawmaskrelease/psecure
41
+ ```
42
+
43
+ **2. Client**: seal the requests your app sends to your API.
44
+
45
+ ```ts
46
+ import { createPSecureFetch } from '@pawmaskrelease/psecure/fetch';
47
+
48
+ export const psecure = createPSecureFetch({
49
+ appKey: 'psec_app_…',
50
+ serverPublicKey: '<64-hex authority public key>',
51
+ apiOrigins: ['https://api.example.com'],
52
+ });
53
+
54
+ await psecure.fetch('https://api.example.com/api/orders', { method: 'POST', body }); // instead of fetch()
55
+ psecure.setAccount(user?.id ?? null); // on sign-in / sign-out
56
+ ```
57
+
58
+ If PSecure is down or misconfigured, `psecure.fetch` sends the request unsealed: your app keeps working and your
59
+ backend decides.
60
+
61
+ **3. Server**: verify every request. Put the secrets in the backend environment:
62
+
63
+ ```bash
64
+ PSECURE_SERVER_KEY=psec_pat_… # the API token (secret)
65
+ PSECURE_ORIGINS=https://api.example.com # the origin your browsers call this API on
66
+ PSECURE_APP_KEY=psec_app_… # this backend's app (recommended: refuses leases of your other apps)
67
+ ```
68
+
69
+ Fastify:
70
+
71
+ ```ts
72
+ import { psecureFastify } from '@pawmaskrelease/psecure/fastify';
73
+ await app.register(psecureFastify);
74
+ ```
75
+
76
+ Express:
77
+
78
+ ```ts
79
+ import { psecure, psecureRawBody } from '@pawmaskrelease/psecure/express';
80
+ app.use(express.json({ verify: psecureRawBody })); // keep the exact body bytes
81
+ app.use('/api', psecure());
82
+ ```
83
+
84
+ Next.js (route handler; also Hono, Workers, Bun, Deno):
85
+
86
+ ```ts
87
+ import { withPSecure } from '@pawmaskrelease/psecure/edge';
88
+ export const POST = withPSecure(async (request) => Response.json({ ok: true }));
89
+ ```
90
+
91
+ If your API is on another origin than your web app, allow the PSecure headers in CORS: request headers
92
+ `PSECURE_REQUEST_HEADERS` and exposed headers `PSECURE_EXPOSED_HEADERS` (both exported by every adapter).
93
+
94
+ **4. Monitor, then Block.** New apps start in **Monitor**: every request is verified and logged
95
+ (`psecure verify` lines, `request.psecure`), nothing is blocked. When the logs look clean, switch the app to
96
+ **Block** in the console: the adapters follow the switch on the next request, no redeploy. (`PSECURE_MODE=monitor |
97
+ enforce | off` overrides the console switch for incidents and tests.)
98
+
99
+ **5. Check the setup.**
100
+
101
+ ```bash
102
+ PSECURE_SERVER_KEY=psec_pat_… npx @pawmaskrelease/psecure doctor \
103
+ --app-key psec_app_… --public-key <64 hex> --origin https://app.example.com
104
+ ```
105
+
106
+ `doctor` prints ✓ / ✗ with a fix for: authority reachable, pinned key, clock, CORS, a test lease with your app key,
107
+ your server key (never printed) and the app pinning (`PSECURE_APP_KEY`). Every misconfiguration the SDK detects at runtime is a `PSecureConfigError` with
108
+ a stable `code` and a `hint` (see [Errors](#errors)). Full docs: https://psecure.pawmask.com/docs
25
109
 
26
110
  ## Install
27
111
 
@@ -40,7 +124,7 @@ Requirements: Node.js >= 20.16.0 or a modern browser (ES2023). Zero runtime depe
40
124
  3. Your **backend** forwards the envelope to the PSecure **Verify API** with request
41
125
  context; Verify returns allow / deny (and policy signals) which your backend enforces.
42
126
 
43
- ## Quick start (web / browser)
127
+ ## `PSecureClient` in detail (web / browser)
44
128
 
45
129
  ### 1. One-call connect (`PSecureClient.connect`)
46
130
 
@@ -58,7 +142,7 @@ const client = await PSecureClient.connect({
58
142
  authorityUrl: 'https://psecure-api.pawmask.com',
59
143
  serverPublicKey: PSECURE_SERVER_PUBLIC_KEY, // REQUIRED (hex, PEM, KeyObject, or an array during key rotation)
60
144
  sealOrigins: ['https://api.example.com'], // origins protectFetch may seal for (default: authority origin)
61
- appKey: 'pk_live_xxx',
145
+ appKey: 'psec_app_…', // app key from the console (not a secret)
62
146
  accountId,
63
147
  // telemetry: 'minimal', onBeforeCollect: (ctx) => userHasConsented, // see "Privacy & consent"
64
148
  });
@@ -202,69 +286,254 @@ What the SDK does:
202
286
 
203
287
  ### 3. Verify on your backend
204
288
 
205
- Use `PSecureRemoteAuthorityClient` and the built-in Express/Edge middlewares from `@pawmaskrelease/psecure/server`, or forward the sealed request headers directly to `POST /v1/request/verify`. (The Split-Key Verification Engine is intentionally excluded from the public npm tarball to prevent offline white-box oracle attacks.)
289
+ > Most backends should use the 1.1 [server adapters](#server-adapters-11) (Fastify, Express, Next.js/Edge). This
290
+ > section documents the lower-level verifier they are built on.
291
+
292
+ `@pawmaskrelease/psecure/server` forwards the sealed request to the hosted authority and returns its verdict.
293
+ Your server key (tenant API key from the PSecure console) is a **secret**: keep it in the server environment only.
294
+ The Split-Key Verification Engine itself is intentionally not in the public npm package (it would be an offline
295
+ white-box oracle).
206
296
 
207
297
  ```ts
208
298
  import {
209
299
  PSecureRemoteAuthorityClient,
210
- createPSecureRemoteExpressMiddleware,
300
+ createPSecureExpressMiddleware,
211
301
  createPSecureRemoteFetchEdgeMiddleware,
212
302
  } from '@pawmaskrelease/psecure/server';
213
303
 
214
- // Option A — Remote Authority Client (Node.js / Fastify / NestJS / custom handlers):
215
- const remoteAuthority = new PSecureRemoteAuthorityClient({
216
- authorityBaseUrl: 'https://psecure-api.pawmask.com',
217
- authorityApiKey: process.env.PSECURE_SERVER_KEY!,
304
+ const authority = new PSecureRemoteAuthorityClient({
305
+ baseUrl: 'https://psecure-api.pawmask.com',
306
+ apiKey: process.env.PSECURE_SERVER_KEY!, // SECRET
307
+ timeoutMs: 1500, // default 3000
308
+ });
309
+ ```
310
+
311
+ **Any Node framework (Fastify, NestJS, custom handlers)** — call the verifier with the request exactly as
312
+ received. `body` must be the raw bytes or string the client sent (not re-serialized JSON):
313
+
314
+ ```ts
315
+ const verdict = await authority.verifyHttpRequestAsync({
316
+ method: req.method,
317
+ origin: ['https://api.example.com'], // origins the envelope may be bound to
318
+ path: req.url, // path + '?' + raw query, as received
319
+ body: req.rawBody, // Buffer / Uint8Array / string
320
+ headers: req.headers, // x-psecure-* headers are read from here
321
+ clientIp: req.ip,
322
+ userAgent: req.headers['user-agent'],
218
323
  });
219
324
 
220
- // Option B — Express / Connect verification middleware:
325
+ if (!verdict.allowed) {
326
+ // verdict.action: 'REISSUE_LEASE' | 'STEP_UP_CHALLENGE' | 'BAN_ACCOUNT_AND_HWID' | 'STATE_STORE_UNAVAILABLE';
327
+ // verdict.reason is a generic code.
328
+ // Never return verdict.detail to the client: it is a server-side diagnostic.
329
+ return reply.code(403).send({ error: 'request_rejected', reason: verdict.reason });
330
+ }
331
+ // verdict.shadowWouldHaveAction is set when the app runs in Monitor (shadow) mode.
332
+ ```
333
+
334
+ **Express / Connect:**
335
+
336
+ ```ts
221
337
  app.use(
222
338
  '/api',
223
- createPSecureRemoteExpressMiddleware({
224
- authorityBaseUrl: 'https://psecure-api.pawmask.com',
225
- authorityApiKey: process.env.PSECURE_SERVER_KEY!,
339
+ createPSecureExpressMiddleware(authority, {
226
340
  origins: ['https://api.example.com'],
341
+ excludePaths: ['/api/health'],
227
342
  }),
228
343
  );
344
+ ```
345
+
346
+ **Fetch / Edge runtimes (Cloudflare Workers, Next.js middleware, Deno, Bun):**
347
+
348
+ ```ts
349
+ const handler = createPSecureRemoteFetchEdgeMiddleware(
350
+ authority,
351
+ (request, verdict) => fetch(request), // downstream; called only for allowed requests
352
+ { origins: ['https://api.example.com'] },
353
+ );
354
+ export default { fetch: handler };
355
+ ```
356
+
357
+ Roll out in two phases. **Monitor** first: verify every request and record the verdicts, but let denied requests
358
+ through — this is a decision of your backend (for example an environment flag around the `if (!verdict.allowed)`
359
+ branch). **Enforce** once the reports look clean. The application mode in the PSecure console (Monitor / Block)
360
+ classifies the app's telemetry; it does not change verdicts. `verdict.shadowWouldHaveAction` is set only when the
361
+ authority deployment itself runs in shadow mode. See https://psecure.pawmask.com/docs/backend/rollout.
362
+
363
+ The HTTP contract of the authority (including `POST /v1/request/verify`) is published as OpenAPI 3.1 in
364
+ `server/api/openapi.json` of the repository.
365
+
366
+ ## Server adapters (1.1)
367
+
368
+ The Fastify plugin, the Express middleware and the Fetch/Edge wrapper share one guard (`createPSecureGuard`, also
369
+ exported for custom integrations). Types are structural: the SDK does not depend on any framework.
370
+
371
+ ### Options
372
+
373
+ Every option falls back to an environment variable, so `psecureFastify` / `psecure()` / `withPSecure` work with no
374
+ arguments.
375
+
376
+ | Option | Env | Default | |
377
+ | --- | --- | --- | --- |
378
+ | `serverKey` | `PSECURE_SERVER_KEY` | (required unless `mode: 'off'`) | API token `psec_pat_…`. Never logged. |
379
+ | `origins` | `PSECURE_ORIGINS` (comma separated) | (required) | Public origin(s) your browsers call this API on. The client seals for them. |
380
+ | `mode` | `PSECURE_MODE` | `'auto'` | `auto` follows the console switch; `monitor` / `enforce` / `off` override it. |
381
+ | `authorityUrl` | `PSECURE_AUTHORITY_URL` | `https://psecure-api.pawmask.com` | https only (http for loopback). |
382
+ | `timeoutMs` | `PSECURE_TIMEOUT_MS` | `1500` | Authority call timeout. |
383
+ | `appKey` | `PSECURE_APP_KEY` | none | **Recommended.** The app this backend serves: sent as `expectedAppKey`, so the authority refuses leases of any other app (`app_mismatch`) and the console mode is only taken for this app. Also scopes the mode cache. Per request: `appKey` on the request (Fastify route config `psecureAppKey`). |
384
+ | `protect` | | `'all'` | Routes that fail closed (see below). Matchers: `'/pay'`, `'/api/*'`, `'/orders/:id'`, `'POST /pay'`, `RegExp`, `(info) => boolean`. |
385
+ | `skip` | | `[]` | Routes never checked (health checks, webhooks). `OPTIONS` is always skipped. |
386
+ | `onDecision` | | | `(decision, { method, path, route, ip }) => void` for metrics / audit. `createPSecureAuditThrottle({ sink })` turns it into deduplicated audit events. |
387
+ | `logger` | | console | pino-compatible `{ info, warn, debug? }` (Fastify uses `app.log`); `false` disables. |
388
+ | `blockedResponse` | | `403 { error: 'PSECURE_REJECTED' }` | Your API's error shape for refused requests. |
389
+
390
+ Fastify extras: route config `{ psecure: true }` protects a route, `{ psecure: false }` skips it. Express extras:
391
+ `maxBodyBytes` (default 1 MiB). Edge extras: `maxBodyBytes`, `clientIp(request)`; `options` may be a function of the
392
+ handler's extra arguments (Cloudflare Workers `env`).
393
+
394
+ ### Decision table
395
+
396
+ | Outcome | Monitor | Block (enforce), `protect` route | Block, other route |
397
+ | --- | --- | --- | --- |
398
+ | verified | served | served | served |
399
+ | denied (tampered, replayed, forged, banned, lease of another app) or malformed envelope | served, logged | **403** | **403** |
400
+ | lease must be renewed, with a reissued lease | served + `x-psecure-reissued-lease` | **401** + `x-psecure-reissued-lease` | **401** + header |
401
+ | no envelope | served | **403** | served |
402
+ | authority down / timeout / key rejected | served | **403** (fail-closed) | served |
403
+
404
+ `createPSecureFetch` adopts a reissued lease on the 401 and retries once. Each decision is available as
405
+ `request.psecure` (Fastify), `req.psecure` / `res.locals.psecure` (Express), or `getPSecureDecision(request)` (all
406
+ adapters, typed): `{ verified, result, action, reason, effectiveMode, modeSource, blocked, wouldBlock,
407
+ envelopeAccountId, ... }`. `envelopeAccountId` is what the client claims: compare it with your session user before
408
+ trusting it.
409
+
410
+ ### Monitor / Block (`mode: 'auto'`)
411
+
412
+ The authority reports the app's console mode on every verdict (`appMode`). In `auto` the guard blocks only while the
413
+ app is in **Block**. When a request carries no verdict (no envelope, authority unreachable), the guard uses the last
414
+ mode it saw for the app, for up to 10 minutes; with nothing known yet (cold start) it behaves as Monitor. Trade-off:
415
+ right after a restart during an authority outage, unsealed requests are served until the first verdict arrives.
416
+ Pin `mode: 'enforce'` (or `PSECURE_MODE=enforce`) where that matters more than availability.
417
+
418
+ Set `PSECURE_APP_KEY`. Without it, the guard follows the mode of whichever app a lease belongs to, so a lease of one
419
+ of your Monitor apps replayed against a Block app's backend would only be logged; the guard warns once at startup
420
+ (`APP_KEY_NOT_PINNED`). With it, that lease is denied (`reason: 'app_mismatch'`) and refused in Block. Each decision says which
421
+ mode applied (`effectiveMode`) and why (`modeSource`: `console`, `cache`, `default` or `config`).
422
+
423
+ ### Raw body
424
+
425
+ PSecure hashes the exact bytes the client sealed, so the adapters need the raw body:
426
+
427
+ - **Fastify** captures it itself (a `preParsing` tap: works with every content-type parser, never parses twice). An
428
+ existing `request.rawBody` is used as is.
429
+ - **Express**: `express.json({ verify: psecureRawBody })` (same for `express.text` / `raw` / `urlencoded`), or mount
430
+ `psecure()` before the body parser: it then reads and parses the body once and the parser skips it. A body consumed
431
+ without its bytes is reported as `raw_body_unavailable` (and refused in Block).
432
+ - **Edge**: read from a clone; your handler gets an unread `request`.
433
+
434
+ ### CORS
229
435
 
230
- // Option C — Fetch / Edge runtime verification middleware (Cloudflare Workers, Next.js, Hono):
231
- const psecVerify = createPSecureRemoteFetchEdgeMiddleware({
232
- authorityVerifyUrl: 'https://psecure-api.pawmask.com/v1/request/verify',
233
- authorityApiKey: process.env.PSECURE_SERVER_KEY!,
436
+ ```ts
437
+ import { PSECURE_REQUEST_HEADERS, PSECURE_EXPOSED_HEADERS } from '@pawmaskrelease/psecure/fastify';
438
+ await app.register(cors, {
439
+ origin: ['https://app.example.com'],
440
+ allowedHeaders: ['content-type', 'authorization', ...PSECURE_REQUEST_HEADERS],
441
+ exposedHeaders: [...PSECURE_EXPOSED_HEADERS],
234
442
  });
443
+ ```
235
444
 
236
- // Option D — Direct HTTP call to POST /v1/request/verify:
237
- const verify = await fetch('https://psecure-api.pawmask.com/v1/request/verify', {
238
- method: 'POST',
239
- headers: {
240
- 'content-type': 'application/json',
241
- 'x-psecure-api-key': process.env.PSECURE_SERVER_KEY!,
242
- },
243
- body: JSON.stringify({
244
- method: req.method,
245
- origin: 'https://api.example.com',
246
- allowedOrigins: ['https://api.example.com'],
247
- path: req.originalUrl ?? req.url,
248
- bodyUtf8: rawRequestBody,
249
- headers: {
250
- 'x-psecure-version': req.headers['x-psecure-version'],
251
- 'x-psecure-envelope': req.headers['x-psecure-envelope'],
252
- 'x-psecure-aead-tag': req.headers['x-psecure-aead-tag'],
253
- },
254
- clientIp: req.ip,
255
- userAgent: req.headers['user-agent'],
256
- }),
445
+ ### Recipes
446
+
447
+ **NestJS** (no Nest dependency): on Express, `const app = await NestFactory.create(AppModule, { rawBody: true });
448
+ app.use(psecure());` (Nest's `rawBody: true` fills `req.rawBody`). On Fastify, `NestFactory.create(AppModule, new
449
+ FastifyAdapter())` then `await app.register(psecureFastify)`. A Nest project compiled to CommonJS needs Node.js
450
+ >= 20.19 or >= 22.12 (`require()` of an ES module).
451
+
452
+ **Hono**:
453
+
454
+ ```ts
455
+ import { createPSecureEdge } from '@pawmaskrelease/psecure/edge';
456
+ const ps = createPSecureEdge();
457
+ app.use('/api/*', async (c, next) => {
458
+ const { decision, response } = await ps.check(c.req.raw);
459
+ if (response) return response;
460
+ await next();
461
+ c.res = ps.finish(c.res, decision);
257
462
  });
258
- const result = await verify.json(); // { allowed, action, reason, ... }
259
463
  ```
260
464
 
465
+ **Cloudflare Workers**: `export default { fetch: withPSecure(handler, (env) => ({ serverKey: env.PSECURE_SERVER_KEY,
466
+ origins: env.PSECURE_ORIGINS, clientIp: (r) => r.headers.get('cf-connecting-ip') ?? undefined })) };`
467
+
468
+ ## Client helper: `createPSecureFetch` (1.1)
469
+
470
+ `@pawmaskrelease/psecure/fetch` wraps `PSecureClient` for apps that just want a sealing `fetch`:
471
+
472
+ | Option | Default | |
473
+ | --- | --- | --- |
474
+ | `appKey`, `serverPublicKey`, `apiOrigins` | (required) | Public values; `serverPublicKey` may be an array during a key rotation. Only `apiOrigins` are sealed. |
475
+ | `authorityUrl` | `https://psecure-api.pawmask.com` | |
476
+ | `enabled` | `true` | Rollout switch: `false` = plain fetch, SDK never loaded. |
477
+ | `accountId` | `null` | Or later `setAccount(id \| null)`. |
478
+ | `telemetry`, `onBeforeCollect` | `'minimal'` | See [Privacy & consent](#privacy--consent). |
479
+ | `waitForConnectMs` | `2000` | How long the first requests wait for the lease before going out unsealed. |
480
+ | `onError`, `strict` | | Called once per problem; `strict: true` throws configuration errors instead. |
481
+
482
+ Returns `{ fetch, setAccount, status, lastError, ready, start, dispose }`. `status()` is `disabled`,
483
+ `misconfigured`, `idle`, `connecting`, `ready` or `failed`. Behaviour:
484
+
485
+ - Graceful fallback: requests go out unsealed while PSecure is disabled, misconfigured, connecting or failing
486
+ (retried with backoff from 30 s to 5 min). A request that already left and failed on the network is rethrown,
487
+ never re-sent unsealed (no duplicated POST).
488
+ - The lease is refreshed in the background when it expires or the API answers `PSECURE_REJECTED`.
489
+ - The SDK core is loaded lazily on the first sealed request (a dynamic import your bundler puts in its own chunk).
490
+ The eager part of `@pawmaskrelease/psecure/fetch` is about 16 KiB gzip; the lazy core about 112 KiB gzip (1.1.0,
491
+ before your bundler's minification).
492
+ - Browser CSP: add the authority origin to `connect-src`. A blocked request is reported as `CSP_BLOCKED`.
493
+
494
+ ## Errors
495
+
496
+ `PSecureConfigError` (`code`, `hint`, `docsUrl`) is thrown at startup by the server adapters and reported by
497
+ `createPSecureFetch` (`lastError()`, `onError`). Runtime problems the guard can only see per request are logged once
498
+ as `psecure config:` lines with the same codes.
499
+
500
+ | Code | Typical cause |
501
+ | --- | --- |
502
+ | `SERVER_KEY_MISSING` | `PSECURE_SERVER_KEY` not set (set `PSECURE_MODE=off` to run without PSecure) |
503
+ | `SERVER_KEY_IS_APP_KEY`, `SERVER_KEY_IN_CLIENT` | app key and API token swapped |
504
+ | `SERVER_KEY_REJECTED` | token revoked/expired, or of another organization (authority answered 401/403) |
505
+ | `PUBLIC_KEY_MISSING`, `PUBLIC_KEY_INVALID`, `PUBLIC_KEY_MISMATCH` | pinned key absent, malformed, or not the authority's key |
506
+ | `APP_KEY_MISSING`, `APP_KEY_INVALID`, `ORG_INACTIVE` | app key absent, unknown to the authority or not of your organization (also `PSECURE_APP_KEY`), organization suspended |
507
+ | `ORIGINS_MISSING`, `ORIGIN_INVALID`, `ORIGIN_MISMATCH` | `PSECURE_ORIGINS` / `apiOrigins` absent, malformed, or not the same on both sides |
508
+ | `AUTHORITY_INSECURE`, `AUTHORITY_INVALID`, `AUTHORITY_UNREACHABLE`, `CSP_BLOCKED` | http authority, bad URL, network/DNS, CSP `connect-src` |
509
+ | `CLOCK_SKEW` | the device or server clock is off |
510
+ | `RAW_BODY_MISSING` | the body parser dropped the raw bytes |
511
+ | `MODE_INVALID` | `PSECURE_MODE` is not `auto`, `monitor`, `enforce` or `off` |
512
+
513
+ ## `doctor` CLI
514
+
515
+ ```bash
516
+ npx @pawmaskrelease/psecure doctor [--authority <url>] [--app-key psec_app_…] [--public-key <hex>]
517
+ [--origin https://app.example.com] [--api https://api.example.com/health] [--json]
518
+ ```
519
+
520
+ Flags fall back to `PSECURE_AUTHORITY_URL`, `PSECURE_APP_KEY`, `PSECURE_PUBLIC_KEY` and `PSECURE_WEB_ORIGIN` (the
521
+ origin your web app runs on; not `PSECURE_ORIGINS`, which is your API's). The server key is read from `PSECURE_SERVER_KEY` only (a `--server-key` flag is refused so it does
522
+ not land in shell history) and is never printed: `doctor` proves it by sending a deliberately unsealed verify request,
523
+ which the authority must answer with a denial. With `PSECURE_APP_KEY` set, the same call proves that key is an app of
524
+ the token's organization; unset, `doctor` warns. Exit code 1 when a check fails.
525
+
261
526
  ## Entry points
262
527
 
263
528
  | Import | What it provides |
264
529
  | --- | --- |
265
530
  | `@pawmaskrelease/psecure` | Client SDK barrel (protocol, crypto, vm, rasp, client, adapters); no server code |
531
+ | `@pawmaskrelease/psecure/fetch` | `createPSecureFetch` (sealing `fetch` with fallback; lazy-loads the client core) |
532
+ | `@pawmaskrelease/psecure/fastify` | **Backend.** `psecureFastify` plugin, `PSECURE_REQUEST_HEADERS`, `PSECURE_EXPOSED_HEADERS` |
533
+ | `@pawmaskrelease/psecure/express` | **Backend.** `psecure()` middleware, `psecureRawBody` |
534
+ | `@pawmaskrelease/psecure/edge` | **Backend / Edge.** `withPSecure`, `createPSecureEdge` (Next.js, Hono, Workers, Bun, Deno) |
266
535
  | `@pawmaskrelease/psecure/client` | `PSecureClient` (`connect` / `sealRequest` / `sealRequestAsync` / `protectFetch` / `dispose`) |
267
- | `@pawmaskrelease/psecure/server` | **Backend / Edge only.** Remote Authority client & verification middlewares: `PSecureRemoteAuthorityClient`, `createExpressMiddleware`, `createPSecureExpressMiddleware`, `createPSecureRemoteExpressMiddleware`, `createPSecureFetchEdgeMiddleware`, `createPSecureRemoteFetchEdgeMiddleware`. (The Split-Key Verification Engine is never shipped in the public npm package to prevent offline white-box oracle attacks; never import `@pawmaskrelease/psecure/server` from browser code.) |
536
+ | `@pawmaskrelease/psecure/server` | **Backend / Edge only.** Remote Authority client & verification middlewares: `PSecureRemoteAuthorityClient`, `createExpressMiddleware`, `createPSecureExpressMiddleware`, `createPSecureRemoteExpressMiddleware`, `createPSecureFetchEdgeMiddleware`, `createPSecureRemoteFetchEdgeMiddleware`, plus the 1.1 guard (`createPSecureGuard`) and the Express / Edge adapters. (The Split-Key Verification Engine is never shipped in the public npm package to prevent offline white-box oracle attacks; never import `@pawmaskrelease/psecure/server` from browser code.) |
268
537
  | `@pawmaskrelease/psecure/protocol` | Wire format, codecs, lease verification, constants, types |
269
538
  | `@pawmaskrelease/psecure/crypto` | Crypto primitives & ciphers |
270
539
  | `@pawmaskrelease/psecure/vm` | `PSecureVm` bytecode VM + polymorphic bundler |
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // `npx @pawmaskrelease/psecure doctor` — see `psecure --help`.
3
+ const [major] = process.versions.node.split('.').map(Number);
4
+ if (major < 20) {
5
+ console.error(`@pawmaskrelease/psecure needs Node.js >= 20 (this is ${process.versions.node}).`);
6
+ process.exit(1);
7
+ }
8
+ const { runPSecureCli } = await import('../dist/cli/doctor.js');
9
+ process.exitCode = await runPSecureCli(process.argv.slice(2), {
10
+ stdout: (t) => console.log(t),
11
+ stderr: (t) => console.error(t),
12
+ env: process.env,
13
+ });
@@ -55,4 +55,4 @@ dispose(){if(this.#_0x1dd12b)
55
55
  return;this.#_0x1dd12b=true;this.#_0x557f55.fill(0);this.#_0xd63721.clear();this.#_0x32df7f.dispose();}}
56
56
  export async function sealDesktopRequestAsync(_0x53637e,_0x02eef0){if(_0x53637e instanceof PSecureDesktopBridge){return _0x53637e.sealDesktopRequestAsync(_0x02eef0);}
57
57
  return _0x53637e.sealRequestAsync({[_0xc9bd50_S(26)]:_0x02eef0.method,[_0xc9bd50_S(27)]:_0x02eef0.path,[_0xc9bd50_S(28)]:_0x02eef0.origin,[_0xc9bd50_S(29)]:_0x02eef0.body,[_0xc9bd50_S(30)]:_0x02eef0.nowMs,});}
58
- /* @psec-self-seal sha256=35a116d1c1fa149f9415efa4bfc47009b148d54598a8a176b4788e4dfe120b4c fnv1a=08e6fb10 */
58
+ /* @psec-self-seal sha256=1789b4905f27eec1dc7c7be9cbd964b8e06e194e710d2ed7cf3606b00c234077 fnv1a=117da010 */
@@ -3,4 +3,4 @@ export * from "./mobile-bridge.js";
3
3
  export * from "./react-native.js";
4
4
  export * from "./desktop.js";
5
5
 
6
- /* @psec-self-seal sha256=4b2feeebab795ca3fac3416571b8bac2dd48cffb8dca888eb3bf004295929f32 fnv1a=08e6fb10 */
6
+ /* @psec-self-seal sha256=2f38a9597f9db18b05602cfca1bafa465b1ac361b00890553793033f004b80d6 fnv1a=117da010 */
@@ -51,4 +51,4 @@ const _0xe2bdfd=this.evaluateMobileSignals(_0x3bcc02);if(_0x567126===PSecureNati
51
51
  if(_0x567126===PSecureNativeCommand.CMD_COLLECT_NATIVE_RASP){const _0x430798=new Uint8Array(8);const _0xa0e68a=new DataView(_0x430798.buffer);_0xa0e68a.setUint32(0,_0xe2bdfd>>>0,true);_0xa0e68a.setUint32(4,psecPolyHash32(_0x954c5b,_0xe2bdfd)>>>0,true);return{[_0x1aa615_S(11)]:0,[_0x1aa615_S(12)]:_0x1aa615_S(2),[_0x1aa615_S(13)]:_0xe2bdfd,[_0x1aa615_S(14)]:bytesToHex(_0x430798),};}
52
52
  const _0x4eace7=new Uint8Array(this.#_0xa727e7);if(_0xe2bdfd!==0){_0x4eace7[0]=(_0x4eace7[0]^(_0xe2bdfd&(0x5a3ee1a5^0x5a3ee15a)))&(0x2f6ed5a5^0x2f6ed55a);_0x4eace7[1]=(_0x4eace7[1]^((_0xe2bdfd>>>8)&(0x1f803ca5^0x1f803c5a)))&(0x64879ea5^0x64879e5a);_0x4eace7[2]=(_0x4eace7[2]^((_0xe2bdfd>>>(0x9e7964a^0x9e7965a))&(0x3fe139a5^0x3fe1395a)))&(0x166786a5^0x1667865a);_0x4eace7[3]=(_0x4eace7[3]^((_0xe2bdfd>>>(0x7bf8ef42^0x7bf8ef5a))&(0x12a035a5^0x12a0355a)))&(0x6c1325a5^0x6c13255a);_0x4eace7[(0x52913045^0x5291305a)]=(_0x4eace7[(0x1f31f645^0x1f31f65a)]^(0x5b85a3ff^0x5b85a35a))&(0x67acf4a5^0x67acf45a);}
53
53
  const _0x088074=psecRandomBytes((0x686bac56^0x686bac5a));const _0xfb6e20=new DataView(_0x088074.buffer,_0x088074.byteOffset,_0x088074.byteLength);_0xfb6e20.setUint32(0,(_0xfb6e20.getUint32(0,true)^(_0x567126>>>0))>>>0,true);_0xfb6e20.setUint32(4,(_0xfb6e20.getUint32(4,true)^(_0x954c5b.length>>>0))>>>0,true);const _0x3271f6=psecChaChaCfbSeal(_0x954c5b,_0x4eace7,_0x088074);_0x4eace7.fill(0);const _0x733c76=new Uint8Array(_0x088074.length+_0x3271f6.ciphertext.length+_0x3271f6.tag.length);_0x733c76.set(_0x088074,0);_0x733c76.set(_0x3271f6.ciphertext,_0x088074.length);_0x733c76.set(_0x3271f6.tag,_0x088074.length+_0x3271f6.ciphertext.length);return{[_0x1aa615_S(11)]:0,[_0x1aa615_S(12)]:_0x1aa615_S(2),[_0x1aa615_S(13)]:_0xe2bdfd,[_0x1aa615_S(14)]:bytesToHex(_0x733c76),[_0x1aa615_S(15)]:bytesToHex(_0x088074),};}}
54
- /* @psec-self-seal sha256=fc388c45ba5c4c0d3e79b215edba8fd46370559e423cfd4c2e0969b27ac41a49 fnv1a=08e6fb10 */
54
+ /* @psec-self-seal sha256=43061b8174042b3ad21ad653d5c37bac4f32dd0c8143c2eb668e220a6e6299cc fnv1a=117da010 */
@@ -43,4 +43,4 @@ pingNativeCore(){this.#_0xafdc03();return this.#_0xae6fa2.dispatch(PSecureNative
43
43
  sealMobileRequest(_0x167378){this.#_0xafdc03();return this.#_0xae6fa2.sealMobileRequest(this.#_0x7902b1,_0x167378,this.#_0xa9f01f);}
44
44
  async sealRequestAsync(_0x167378){this.#_0xafdc03();return this.#_0xae6fa2.sealMobileRequestAsync(this.#_0x7902b1,_0x167378,this.#_0xa9f01f);}
45
45
  async sealMobileRequestAsync(_0x167378){return this.sealRequestAsync(_0x167378);}}
46
- /* @psec-self-seal sha256=312ac98508db64906d49f12772b58dc257d0afc7390b7e042185e86ae448e4e3 fnv1a=08e6fb10 */
46
+ /* @psec-self-seal sha256=30fd0e998f80cc8e85dab875d3bc52dd90d456c40ea02e64d202e1f78afc0998 fnv1a=117da010 */
@@ -50,4 +50,4 @@ return{[_0x63aba3_S(17)]:_0x219643.length===0,[_0x63aba3_S(18)]:_0x219643,[_0x63
50
50
  createProtectedFetchInterceptor(_0x81dde3,_0x58a799){return(_0xe81982,_0xf1ec43)=>{if(_0x81dde3.requiresAsyncSealing){throw new Error(_0x63aba3_S(4));}
51
51
  const _0xb962a1=globalThis.location;const _0x22312b=typeof _0xb962a1?.href===_0x63aba3_S(5)&&/^https?:/i.test(_0xb962a1.href)?_0xb962a1.href:undefined;const _0xd56d2b=typeof _0xe81982===_0x63aba3_S(5)?new URL(_0xe81982,_0x22312b):new URL(_0xe81982.href);const origin=canonicalizeOrigin(_0xd56d2b);if(!_0x81dde3.isSealOriginAllowed(origin)){throw new Error(`PSecureWebShield: refusing to seal for non-allowlisted origin ${origin} (configure sealOrigins)`);}
52
52
  const _0xbb457f=_0x58a799();const _0xb4f832=this.verifyDomSimHashDrift(_0xbb457f.domTokens);const _0x795be8=this.auditScriptTagsForSkimming(_0xbb457f.scripts);const _0x91cb3a=_0xb4f832.raspBit|_0x795be8.raspBit;return _0x81dde3.sealRequest({[_0x63aba3_S(19)]:_0xf1ec43?.method??_0x63aba3_S(6),[_0x63aba3_S(20)]:origin,[_0x63aba3_S(21)]:buildPathAndQuery(_0xd56d2b),[_0x63aba3_S(22)]:(_0xf1ec43?.body??''),[_0x63aba3_S(23)]:_0x91cb3a!==0?_0x91cb3a:undefined,});};}}
53
- /* @psec-self-seal sha256=7755ba6eaa2e9c155a63dd6a4c443252275e0d5af2c24bb9dbfb9c027c0d2f37 fnv1a=08e6fb10 */
53
+ /* @psec-self-seal sha256=47abfa9f579d6822bcc6cf9bcc6e48c3815e142c57dc4c3d9a11e2c259d8abab fnv1a=117da010 */
@@ -127,4 +127,4 @@ _0x69cf3d.poisonMask)>>>
127
127
  return;}
128
128
  if(!handlerObj||typeof handlerObj!==_0x1b7e94_S((0x42513f4b^0x42513f5a))){return;}
129
129
  const _0x0fb898=handlerObj;if(typeof _0x0fb898.PS762===_0x1b7e94_S((0x2d3c9c78^0x2d3c9c5a))){handshakeCompleted=true;const _0xa458a0=_0x0fb898.PS762;_0x0fb898.PS762=()=>{};_0xa458a0(Object.freeze(_0x802124),_0xc00404,_0xf1626f);}},});return{[_0x1b7e94_S(119)]:_0xb6fbb2};}
130
- /* @psec-self-seal sha256=02d13de6855c11208807183f9fe8f19110060915bf280d183ee5571d9994f6d7 fnv1a=08e6fb10 */
130
+ /* @psec-self-seal sha256=a211895f858b8ee7b9dd4c247af008e5ee41715a10b9ecb22eba33daa6d33a6f fnv1a=117da010 */
@@ -0,0 +1,31 @@
1
+ import { type PSecureConfigErrorCode } from '../errors.js';
2
+ export interface PSecureDoctorOptions {
3
+ authority?: string;
4
+ appKey?: string;
5
+ publicKey?: string;
6
+ origin?: string;
7
+ api?: string;
8
+ serverKey?: string;
9
+ pinnedAppKey?: string;
10
+ timeoutMs?: number;
11
+ fetch?: typeof fetch;
12
+ now?: () => number;
13
+ }
14
+ export type PSecureDoctorStatus = 'pass' | 'fail' | 'warn' | 'skip';
15
+ export interface PSecureDoctorCheck {
16
+ id: string;
17
+ title: string;
18
+ status: PSecureDoctorStatus;
19
+ detail: string;
20
+ fix?: string;
21
+ code?: PSecureConfigErrorCode;
22
+ }
23
+ export declare function runPSecureDoctor(opts: PSecureDoctorOptions): Promise<PSecureDoctorCheck[]>;
24
+ export declare function formatPSecureDoctorReport(checks: readonly PSecureDoctorCheck[]): string;
25
+ export interface PSecureCliIo {
26
+ stdout: (text: string) => void;
27
+ stderr: (text: string) => void;
28
+ env: Record<string, string | undefined>;
29
+ fetch?: typeof fetch;
30
+ }
31
+ export declare function runPSecureCli(argv: readonly string[], io: PSecureCliIo): Promise<number>;