@web-ts-toolkit/express-request-attestation 0.51.0 → 0.52.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.
- package/README.md +352 -56
- package/index.d.mts +212 -103
- package/index.d.ts +212 -103
- package/index.js +31 -56
- package/index.mjs +31 -56
- package/package.json +1 -1
- package/signer.d.mts +196 -93
- package/signer.d.ts +196 -93
- package/signer.js +454 -326
- package/signer.mjs +454 -326
- package/{time-policy-DlER-7rf.d.mts → time-policy-CquS0Zgm.d.mts} +46 -25
- package/{time-policy-DlER-7rf.d.ts → time-policy-CquS0Zgm.d.ts} +46 -25
package/README.md
CHANGED
|
@@ -31,15 +31,18 @@ failure or a lost response; a fresh proof is required on a new attempt.
|
|
|
31
31
|
|
|
32
32
|
## Install
|
|
33
33
|
|
|
34
|
-
Backend consumers install Express explicitly (optional
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
Backend consumers need Node `>=22` and install Express explicitly (optional
|
|
35
|
+
peer `>=5`). TypeScript backend consumers also install `@types/express` v5
|
|
36
|
+
and `@types/node`: the root declarations expose Express's router type. Redis
|
|
37
|
+
and MongoDB are caller-injected through structural types; install a driver
|
|
38
|
+
only when using that store.
|
|
38
39
|
|
|
39
40
|
```sh
|
|
40
41
|
# Backend (Express >= 5 required at runtime)
|
|
41
42
|
pnpm add @web-ts-toolkit/express-request-attestation express
|
|
42
|
-
#
|
|
43
|
+
# Backend TypeScript declarations
|
|
44
|
+
pnpm add -D typescript @types/express@^5 @types/node
|
|
45
|
+
# Browser-only (no Express, Redis, MongoDB, or Node types needed)
|
|
43
46
|
pnpm add @web-ts-toolkit/express-request-attestation
|
|
44
47
|
# With shared Redis replay state (backend only)
|
|
45
48
|
pnpm add redis
|
|
@@ -47,6 +50,10 @@ pnpm add redis
|
|
|
47
50
|
pnpm add mongodb
|
|
48
51
|
```
|
|
49
52
|
|
|
53
|
+
The backend examples compile with `strict: true`, `skipLibCheck: false`, and
|
|
54
|
+
`esModuleInterop: true`. Browser consumers use ES2022 + DOM/DOM.Iterable
|
|
55
|
+
libraries; the `/signer` declarations also compile with `types: []`.
|
|
56
|
+
|
|
50
57
|
## Entry imports
|
|
51
58
|
|
|
52
59
|
```ts
|
|
@@ -104,18 +111,24 @@ const entry = generateAttestationKey('v1-20261002-01', {
|
|
|
104
111
|
const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
|
|
105
112
|
const store = createMemoryAttestationStore();
|
|
106
113
|
const capture = createAttestationBodyCapture({ maxBodyBytes });
|
|
107
|
-
const guard = createRequestAttestationMiddleware({
|
|
114
|
+
const guard: express.RequestHandler = createRequestAttestationMiddleware({
|
|
108
115
|
publicOrigin,
|
|
109
116
|
replayNamespace,
|
|
110
117
|
keyProvider,
|
|
111
118
|
store,
|
|
112
119
|
});
|
|
120
|
+
const parser: express.RequestHandler = express.json({
|
|
121
|
+
limit: maxBodyBytes,
|
|
122
|
+
verify: capture.verify,
|
|
123
|
+
inflate: false,
|
|
124
|
+
});
|
|
125
|
+
const captureErrorHandler: express.ErrorRequestHandler = capture.errorHandler;
|
|
113
126
|
|
|
114
127
|
const app = express();
|
|
115
128
|
app.use('/attestation', createSignerBundleRouter({ publicOrigin, replayNamespace, keyProvider }));
|
|
116
|
-
app.use(
|
|
117
|
-
app.use(
|
|
118
|
-
app.post('/api/submit', guard
|
|
129
|
+
app.use(parser);
|
|
130
|
+
app.use(captureErrorHandler);
|
|
131
|
+
app.post('/api/submit', guard, (_req, res) => {
|
|
119
132
|
res.json({ ok: true });
|
|
120
133
|
});
|
|
121
134
|
|
|
@@ -129,6 +142,14 @@ are the same representation. V1 accepts identity or absent
|
|
|
129
142
|
uses SHA-256 of zero bytes. A nonempty body whose parser failed to capture
|
|
130
143
|
it is `500 ATTESTATION_BODY_CAPTURE_REQUIRED`, not the empty digest.
|
|
131
144
|
|
|
145
|
+
Use the same `capture.verify` with `express.urlencoded`, `express.text`, or
|
|
146
|
+
`express.raw` for the body types your routes accept. For exact multipart
|
|
147
|
+
bytes, capture with `express.raw({ type: 'multipart/*', ... })` before your
|
|
148
|
+
application's multipart interpretation. Do not reconstruct a digest from
|
|
149
|
+
parsed JSON or trust a client-provided digest. The capture error handler
|
|
150
|
+
maps parser size/encoding failures to fixed 413/415 responses and forwards
|
|
151
|
+
other parser errors to the application's error handling.
|
|
152
|
+
|
|
132
153
|
### Public keyring provisioning
|
|
133
154
|
|
|
134
155
|
One public keyring example. Provisioning is application-owned (cron, KMS, or
|
|
@@ -188,7 +209,7 @@ export { client, keyProvider, store };
|
|
|
188
209
|
```
|
|
189
210
|
|
|
190
211
|
One same-slot sorted set per shared prefix holds opaque replay keys with
|
|
191
|
-
absolute `
|
|
212
|
+
absolute `retainUntilMs` scores. All clients sharing a prefix must configure
|
|
192
213
|
identical `maxEntries`. Size the ledger as unique accepted requests per second
|
|
193
214
|
times maximum retention plus headroom; actual Redis memory and throughput
|
|
194
215
|
require measurement. Use a dedicated `noeviction` deployment when relying on
|
|
@@ -217,10 +238,17 @@ const entry = generateAttestationKey('v1-mongo-01', {
|
|
|
217
238
|
const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
|
|
218
239
|
const client = new MongoClient('mongodb://127.0.0.1:27017/?replicaSet=rs0');
|
|
219
240
|
await client.connect();
|
|
241
|
+
const db = client.db('app-attestation');
|
|
242
|
+
const proofsCollectionName = 'attestation_proofs';
|
|
243
|
+
const capacityCollectionName = 'attestation_replay_capacity';
|
|
244
|
+
// Provision before accepting traffic. No expireAfterSeconds: this is NOT a TTL index.
|
|
245
|
+
await db
|
|
246
|
+
.collection(capacityCollectionName)
|
|
247
|
+
.createIndex({ kind: 1, retainUntilMs: 1 }, { name: 'attestation_reservation_expiry' });
|
|
220
248
|
const store = createMongoAttestationStore({
|
|
221
|
-
db
|
|
222
|
-
proofsCollectionName
|
|
223
|
-
capacityCollectionName
|
|
249
|
+
db,
|
|
250
|
+
proofsCollectionName,
|
|
251
|
+
capacityCollectionName,
|
|
224
252
|
maxEntries: 50000,
|
|
225
253
|
});
|
|
226
254
|
|
|
@@ -233,10 +261,13 @@ replay keys with absolute `retainUntilMs` deadlines. Each admission runs one
|
|
|
233
261
|
snapshot transaction: duplicate check, bounded expiry reclaim (the requested
|
|
234
262
|
expired entry plus at most 64 expired entries), capacity check, then insert.
|
|
235
263
|
Duplicates return `'duplicate'` even when full and never extend expiry; no
|
|
236
|
-
live entry is ever evicted.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
264
|
+
live entry is ever evicted. MongoDB supplies the unique `_id` indexes; the
|
|
265
|
+
compound non-TTL index above supports bounded expiry lookup and sorting.
|
|
266
|
+
The application owns index provisioning/readiness: the store constructor
|
|
267
|
+
performs no database I/O and does not create indexes. `retainUntilMs` is
|
|
268
|
+
numeric epoch milliseconds. Do not rely on TTL cleanup for correctness and
|
|
269
|
+
never add a TTL index to the capacity/accounting collection or independently
|
|
270
|
+
delete its live rows.
|
|
240
271
|
|
|
241
272
|
All clients sharing one database/collections must configure identical
|
|
242
273
|
`maxEntries` (default 50000, positive safe integer) and synchronized clocks
|
|
@@ -253,15 +284,41 @@ database. Client lifecycle stays caller-owned (connect/close the
|
|
|
253
284
|
`MongoClient` in application code).
|
|
254
285
|
|
|
255
286
|
When to choose a store: memory for a single instance with no shared state;
|
|
256
|
-
Redis for
|
|
287
|
+
Redis for shared replay with atomic Lua admission and server `TIME`; MongoDB when
|
|
257
288
|
deployments already run a replica set and prefer shared replay without
|
|
258
289
|
operating Redis, accepting one transaction per admission.
|
|
259
290
|
|
|
291
|
+
### Dependency deadlines and ownership
|
|
292
|
+
|
|
293
|
+
The guard and asset router bound each asynchronous dependency wait with
|
|
294
|
+
`operationTimeoutMs` (default 5000 ms, integer range 1–60000). This is a
|
|
295
|
+
**waiting bound**, not cancellation of provider work, Redis commands, MongoDB
|
|
296
|
+
transactions/retries, or session cleanup. A late reservation may commit;
|
|
297
|
+
the timed-out request never resumes its handler or releases that reservation.
|
|
298
|
+
No store failure falls back to process-local memory.
|
|
299
|
+
|
|
300
|
+
Configure connection/server-selection, socket/command, pool-wait, and
|
|
301
|
+
transaction/commit deadlines at the injected driver/provider boundary, with
|
|
302
|
+
bounded retry and concurrency policies appropriate to the deployment.
|
|
303
|
+
Direct `store.reserve()` calls have no package-level wait timeout. The caller
|
|
304
|
+
owns client connection, error listeners, readiness, and shutdown; the Mongo
|
|
305
|
+
adapter owns its per-admission session but does not bound driver cleanup.
|
|
306
|
+
Database persistence, failover, and restore settings must meet the application's
|
|
307
|
+
replay-history requirements; losing live replay state can admit an old proof
|
|
308
|
+
again while it is still valid.
|
|
309
|
+
|
|
260
310
|
### Bearer-auth composition
|
|
261
311
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
312
|
+
The guard never sets `req.auth`, never enrolls or validates a DPoP binding,
|
|
313
|
+
and never rewrites authentication. Public HMAC material authenticates neither
|
|
314
|
+
a caller nor a session. The small bearer check below illustrates middleware
|
|
315
|
+
ordering; replace it with your application's actual token/session validation.
|
|
316
|
+
|
|
317
|
+
Putting **auth before the guard** avoids replay allocation for requests auth
|
|
318
|
+
rejects. Putting the guard first can reject bad proofs before expensive auth
|
|
319
|
+
work, but a valid public-HMAC proof reserves replay capacity even if auth later
|
|
320
|
+
rejects. Choose the ordering deliberately; captured body bytes must be
|
|
321
|
+
available before the guard in either case. This example runs auth first.
|
|
265
322
|
|
|
266
323
|
```ts
|
|
267
324
|
import express from 'express';
|
|
@@ -284,7 +341,18 @@ const entry = generateAttestationKey('v1-bearer-01', {
|
|
|
284
341
|
const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
|
|
285
342
|
const store = createMemoryAttestationStore();
|
|
286
343
|
const capture = createAttestationBodyCapture({ maxBodyBytes });
|
|
287
|
-
const guard = createRequestAttestationMiddleware({
|
|
344
|
+
const guard: express.RequestHandler = createRequestAttestationMiddleware({
|
|
345
|
+
publicOrigin,
|
|
346
|
+
replayNamespace,
|
|
347
|
+
keyProvider,
|
|
348
|
+
store,
|
|
349
|
+
});
|
|
350
|
+
const parser: express.RequestHandler = express.json({
|
|
351
|
+
limit: maxBodyBytes,
|
|
352
|
+
verify: capture.verify,
|
|
353
|
+
inflate: false,
|
|
354
|
+
});
|
|
355
|
+
const captureErrorHandler: express.ErrorRequestHandler = capture.errorHandler;
|
|
288
356
|
|
|
289
357
|
function requireBearerAuth(req: express.Request, res: express.Response, next: express.NextFunction): void {
|
|
290
358
|
if (req.headers.authorization !== 'Bearer good-token') {
|
|
@@ -295,9 +363,9 @@ function requireBearerAuth(req: express.Request, res: express.Response, next: ex
|
|
|
295
363
|
}
|
|
296
364
|
|
|
297
365
|
const app = express();
|
|
298
|
-
app.use(
|
|
299
|
-
app.use(
|
|
300
|
-
app.post('/api/submit',
|
|
366
|
+
app.use(parser);
|
|
367
|
+
app.use(captureErrorHandler);
|
|
368
|
+
app.post('/api/submit', requireBearerAuth, guard, (_req, res) => {
|
|
301
369
|
res.json({ ok: true });
|
|
302
370
|
});
|
|
303
371
|
|
|
@@ -310,6 +378,15 @@ selected API routes. DPoP composition uses existing public vault APIs through
|
|
|
310
378
|
the same selected-route pattern. Fingerprint or recognition collection is not
|
|
311
379
|
implemented by this package.
|
|
312
380
|
|
|
381
|
+
Every application auth retry makes a **fresh `fetchWithAttestation` call**
|
|
382
|
+
and obtains a fresh proof; a finite caller-owned `Request` can be reused as
|
|
383
|
+
described below. The wrapper does not automatically retry 401/DPoP challenges.
|
|
384
|
+
For business retries after a lost response or handler failure, persist an
|
|
385
|
+
application idempotency key bound to the authenticated actor and operation,
|
|
386
|
+
atomically with the business effect. A new nonce alone can execute the same
|
|
387
|
+
operation again. Auth, authorization, CSRF policy, and durable business
|
|
388
|
+
idempotency remain application responsibilities.
|
|
389
|
+
|
|
313
390
|
### Frontend quickstart (signer client + prepared fetch)
|
|
314
391
|
|
|
315
392
|
Same-origin default. Caller JSON objects are not valid Fetch bodies: callers
|
|
@@ -350,10 +427,91 @@ replay, invalid proofs, `5xx`, auth failures, redirects, and network
|
|
|
350
427
|
uncertainty never retry. A second stale challenge stops. Returned responses
|
|
351
428
|
remain readable and retries never reuse the old envelope.
|
|
352
429
|
|
|
430
|
+
### Request reuse, abort, and response ownership
|
|
431
|
+
|
|
432
|
+
With a standard same-realm `Request`, an omitted, `undefined`, or `null`
|
|
433
|
+
`init.body` **inherits** its body (`null` does not remove it). The wrapper
|
|
434
|
+
clones that body synchronously before reading, so finite POST requests support
|
|
435
|
+
repeated and concurrent calls and the original Request remains readable.
|
|
436
|
+
Start inherited-body calls before the caller uses, locks, or cancels its body;
|
|
437
|
+
already-used/locked inherited bodies fail with `SIGNER_PREPARATION_FAILED`.
|
|
438
|
+
Native cloning can replace the Request's internal stream: reuse applies to
|
|
439
|
+
the original Request's current body, not a previously retained raw stream
|
|
440
|
+
reference. Native tees may buffer an unread caller branch outside this
|
|
441
|
+
library's reader-owned byte limit.
|
|
442
|
+
|
|
443
|
+
A non-null `init.body`, including `''` or zero bytes, **replaces** the body.
|
|
444
|
+
An independent replacement leaves the original body alone even if the
|
|
445
|
+
original is used/locked. An explicitly supplied `ReadableStream` transfers
|
|
446
|
+
reading/cancellation ownership after native Request construction succeeds;
|
|
447
|
+
this includes an explicit alias of the original's body and is consumptive.
|
|
448
|
+
Construction failure leaves an unaccepted explicit stream caller-owned.
|
|
449
|
+
`init.mode` is sampled once before `init.body`, which is also sampled once.
|
|
450
|
+
Other options follow native Fetch precedence: `init.headers` replaces rather
|
|
451
|
+
than merges inherited headers, and a nonempty init resets inherited
|
|
452
|
+
`referrerPolicy` unless supplied explicitly. Body overrides inherit Content-Type
|
|
453
|
+
unless you replace the headers. When overriding with `FormData`, supply headers
|
|
454
|
+
without the old/manual Content-Type so Fetch generates the new multipart boundary.
|
|
455
|
+
|
|
456
|
+
The first stale Response stays unconsumed and unlocked until a valid retry
|
|
457
|
+
Request is built. Reload, signing, retry-construction/proof-retention failure,
|
|
458
|
+
or a repeated envelope returns that **same readable first Response**.
|
|
459
|
+
Only after choosing the retry is the abandoned first body cancelled, without
|
|
460
|
+
awaiting producer cleanup. Cancellation rejection is observed; cancellation
|
|
461
|
+
does not guarantee producer, native tee, or connection cleanup has finished.
|
|
462
|
+
Once dispatched, a retry transport failure propagates and there is no third
|
|
463
|
+
attempt or fallback to the abandoned response.
|
|
464
|
+
|
|
465
|
+
During body reading or initial/replacement signer waiting, caller abort
|
|
466
|
+
rejects with the caller's exact reason and abandons only this invocation's
|
|
467
|
+
owned bodies. The shared signer client remains caller-owned and usable;
|
|
468
|
+
the wrapper never disposes it or cancels native module/crypto execution.
|
|
469
|
+
Native Fetch (or the injected `fetch`) owns dispatched request readers and
|
|
470
|
+
must honor the forwarded signal. There is no new API transport deadline;
|
|
471
|
+
use the request signal and an application retry policy for that boundary.
|
|
472
|
+
`materializeRequestBodyBytes` is a lower-level **consuming** helper: clone its
|
|
473
|
+
input yourself if you need reuse.
|
|
474
|
+
|
|
475
|
+
### Browser transport and body bounds
|
|
476
|
+
|
|
477
|
+
Effective `mode: 'no-cors'` is unsupported because it cannot carry the proof.
|
|
478
|
+
Omitted/undefined mode inherits the Request's mode or native default;
|
|
479
|
+
explicit `cors` or `same-origin` can override it. Choose a browser-writable
|
|
480
|
+
proof header such as `x-client-transaction-id` or `x-request-proof`.
|
|
481
|
+
The wrapper applies these browser constraints even with an injected transport
|
|
482
|
+
or under Node. Statically incompatible modes/names fail before body ownership,
|
|
483
|
+
signer loading, or API calls. Every constructed signed Request is also checked
|
|
484
|
+
for the exact proof; a stripped, normalized, replaced, or appended proof never
|
|
485
|
+
reaches transport on that attempt.
|
|
486
|
+
|
|
487
|
+
In addition to shared HTTP token/length/reserved-name rules, the wrapper rejects
|
|
488
|
+
case-insensitive `sec-*`/`proxy-*`, `Accept-Charset`, `Accept-Encoding`,
|
|
489
|
+
`Access-Control-Request-Headers`, `Access-Control-Request-Method`, `Connection`,
|
|
490
|
+
`Cookie2`, `Date`, `DNT`, `Expect`, `Keep-Alive`, `Referer`, `TE`, `Trailer`,
|
|
491
|
+
`Upgrade`, `Via`, and `User-Agent`. These browser-only failures and `no-cors`
|
|
492
|
+
use `SignerClientError` / `SIGNER_PREPARATION_FAILED`; malformed/reserved names
|
|
493
|
+
already rejected by shared validation use `AttestationProtocolError`.
|
|
494
|
+
Server HTTP header validation remains separate. Ordinary custom names and
|
|
495
|
+
the three method-override fields (`X-HTTP-Method`, `X-HTTP-Method-Override`,
|
|
496
|
+
`X-Method-Override`) accept real envelopes, subject to the final Request check.
|
|
497
|
+
|
|
353
498
|
Body profile: finite `BodyInit` plus replayable `Request`s within
|
|
354
499
|
`maxBodyBytes` (default 1 MiB, hard cap 16 MiB) and a bounded read deadline
|
|
355
|
-
(
|
|
356
|
-
|
|
500
|
+
(wrapper option `bodyReadTimeoutMs`, default 5000 ms, integer range 1–60000).
|
|
501
|
+
`materializeRequestBodyBytes` calls the same deadline option `timeoutMs`.
|
|
502
|
+
`FormData` is serialized through a `Request` once. Finite byte streams must
|
|
503
|
+
finish within both bounds; unbounded/stalled uploads and non-identity
|
|
504
|
+
`Content-Encoding` are rejected locally.
|
|
505
|
+
|
|
506
|
+
Body and discovery readers check the cap before copying an overflowing chunk,
|
|
507
|
+
discard empty chunks, and coalesce tiny chunks into geometrically grown byte
|
|
508
|
+
storage. Configured read deadlines are checked between reads, and all readers
|
|
509
|
+
yield periodically to allow caller-abort/deadline tasks to run. Limit/deadline
|
|
510
|
+
failures are local `SignerClientError`s
|
|
511
|
+
(`SIGNER_PREPARATION_FAILED` for bodies); body caller-abort preserves the abort
|
|
512
|
+
reason. Failure signals cancellation, observes its rejection without awaiting
|
|
513
|
+
producer cleanup, and releases the reader lock, timers, and abort listener.
|
|
514
|
+
The storage bound applies to reader-owned bytes, not producer/browser queues.
|
|
357
515
|
|
|
358
516
|
## Signer assets and caching
|
|
359
517
|
|
|
@@ -365,46 +523,131 @@ application at a configured base path (default `/attestation`). Routes:
|
|
|
365
523
|
`no-store`; it serves the current active key only and contains no
|
|
366
524
|
session or user credential.
|
|
367
525
|
|
|
526
|
+
The application must mount the router at its configured `basePath`:
|
|
527
|
+
|
|
528
|
+
| `basePath` | `publicPathPrefix` | Application mount | External metadata path |
|
|
529
|
+
| ------------------------ | ------------------ | --------------------------------- | ---------------------------- |
|
|
530
|
+
| `/attestation` (default) | omitted | `app.use('/attestation', router)` | `/attestation/signer-meta` |
|
|
531
|
+
| `/` | omitted | `app.use('/', router)` | `/signer-meta` |
|
|
532
|
+
| `/` | `/prefix` | `app.use('/', router)` | `/prefix/signer-meta` |
|
|
533
|
+
| `/custom` | omitted | `app.use('/custom', router)` | `/custom/signer-meta` |
|
|
534
|
+
| `/custom` | `/prefix` | `app.use('/custom', router)` | `/prefix/custom/signer-meta` |
|
|
535
|
+
|
|
536
|
+
At the root mount, the sibling assets are `/signer.<hash>.mjs` and
|
|
537
|
+
`/runtime.<hash>.mjs`, or `/prefix/signer.<hash>.mjs` and
|
|
538
|
+
`/prefix/runtime.<hash>.mjs` behind that stripped prefix. The key module's
|
|
539
|
+
`./runtime.<hash>.mjs` import resolves beside it. `publicPathPrefix` describes
|
|
540
|
+
a prefix already stripped by the upstream proxy; the caller configures that
|
|
541
|
+
proxy and mounts only the internal base path. Published URLs come from trusted
|
|
542
|
+
configuration, never request headers. This asset URL join does not normalize
|
|
543
|
+
signed request paths: duplicate slashes, percent-escape spelling, and query
|
|
544
|
+
order remain significant.
|
|
545
|
+
|
|
546
|
+
Discovery validates `now()` before provider work and again after copying its
|
|
547
|
+
returned snapshot. Each value must be nonnegative safe-integer epoch
|
|
548
|
+
milliseconds. The current key must satisfy `acceptFrom <= nowMs < acceptUntil`
|
|
549
|
+
at the post-read clock; a retained alternate key is not substituted. Inactive
|
|
550
|
+
or missing current keys, provider errors, malformed snapshots, and provider
|
|
551
|
+
timeouts return fixed `503 ATTESTATION_KEYS_UNAVAILABLE`; invalid/throwing
|
|
552
|
+
clocks return `500 ATTESTATION_INTERNAL_ERROR`. Both use `no-store` and no
|
|
553
|
+
stale-key retry marker.
|
|
554
|
+
|
|
368
555
|
The key-specific ESM wrapper imports `createRequestSigner` from the
|
|
369
556
|
content-addressed runtime module and exports `signer`. The runtime is the
|
|
370
557
|
packaged browser `signer.mjs`, so route assets and the npm browser entry
|
|
371
558
|
share the codec and signer source. The 64-hex content hash identifies exact
|
|
372
559
|
immutable bytes; never serve different content from the same URL. Assets are
|
|
373
560
|
public, served as JavaScript with `nosniff`, cacheable with `immutable`
|
|
374
|
-
(runtime cap 512 KiB, key module cap 8 KiB).
|
|
561
|
+
(runtime cap 512 KiB, key module cap 8 KiB). Malformed asset paths/hashes return `404`;
|
|
375
562
|
syntactically valid but not retained hashes return `410`; neither redirects
|
|
376
563
|
to latest. CORS and CSP stay application-configured.
|
|
377
564
|
|
|
378
|
-
`fetchSignerBundle`
|
|
565
|
+
`fetchSignerBundle` reads metadata incrementally (4096-byte cap) with noncached,
|
|
379
566
|
credential-omitting, redirect-rejecting defaults, validates origin, namespace,
|
|
380
567
|
and version, and loads via native dynamic `import()` (default loader) for a
|
|
381
|
-
URL on the configured API origin.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
568
|
+
URL on the configured API origin. Injected fetch implementations must return
|
|
569
|
+
a standard `Response` with a readable byte body; `arrayBuffer()`/`text()`-only
|
|
570
|
+
substitutes are rejected because they cannot enforce the incremental cap.
|
|
571
|
+
|
|
572
|
+
Standalone `fetchSignerBundle` has a fixed **5000 ms response-body read
|
|
573
|
+
deadline**, starting when the accepted metadata response arrives and reading
|
|
574
|
+
begins. It excludes waiting for response headers and importing/evaluating the
|
|
575
|
+
module. Its `signal` propagates to native discovery fetch and body reading;
|
|
576
|
+
metadata read/limit/abort failures reject with `SIGNER_LOAD_FAILED`.
|
|
577
|
+
|
|
578
|
+
`createSignerClient` owns at most one active signer and one in-flight load
|
|
579
|
+
(single-flight, no cross-tab claim) with
|
|
580
|
+
generation-aware `invalidate(observedKeyId)` and terminal `dispose()`. Its
|
|
581
|
+
`loadTimeoutMs` (default 5000 ms, integer range 1–60000) bounds the **whole load**:
|
|
582
|
+
waiting for discovery headers, body reading, module import, and the one
|
|
583
|
+
retirement refetch. Client metadata has no additional fixed 5000 ms read limit.
|
|
584
|
+
Whole-load expiry rejects with `SIGNER_LOAD_TIMEOUT` and signals metadata
|
|
585
|
+
cancellation. Pending replacements, including explicit recovery after a failed
|
|
586
|
+
replacement, share challenges from **any older issued key**, even multiple
|
|
587
|
+
rotations behind: a late K1 challenge cannot evict the pending K3 replacement
|
|
588
|
+
for K2. An installed newer key survives old-key challenges; a newly installed
|
|
589
|
+
signer can be invalidated again even with the same key ID. Initial-load
|
|
590
|
+
invalidation is supported and malformed observed key IDs are ignored.
|
|
591
|
+
|
|
592
|
+
A metadata-to-module retirement race may refetch discovery once. Recovery
|
|
593
|
+
compares validated `(keyId, absolute module URL)` identity: a changed URL can
|
|
594
|
+
recover under the same key, while equivalent URLs count as unchanged. Unchanged
|
|
595
|
+
identity or a second module failure ends that load; a later explicit
|
|
596
|
+
`getSigner()` can recover. This does not add API transport retries.
|
|
597
|
+
Timeout stops waiting; terminal `dispose()` signals the current metadata fetch
|
|
598
|
+
and prevents late installation. Obsolete loads cannot alter a replacement.
|
|
599
|
+
Neither operation cancels native module evaluation or erases its cache.
|
|
600
|
+
`createRequestSigner` also shares lazy WebCrypto key import: concurrent failure
|
|
601
|
+
rejects with `SignerClientError`, clears that failed import, and permits a later
|
|
602
|
+
explicit sign to retry. Public material may remain in the native module cache.
|
|
387
603
|
|
|
388
604
|
## Time policy
|
|
389
605
|
|
|
390
606
|
```text
|
|
391
607
|
reject future proof if timestampMs > nowMs + clockSkewMs
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
reject when nowMs >=
|
|
395
|
-
|
|
608
|
+
timeDeadlineMs = timestampMs + maxAgeMs + clockSkewMs
|
|
609
|
+
proofDeadlineMs = min(timeDeadlineMs, acceptedKey.acceptUntil)
|
|
610
|
+
reject when nowMs >= proofDeadlineMs or acceptedKey is not active at nowMs
|
|
611
|
+
retainUntilMs = proofDeadlineMs + clusterClockGuardMs
|
|
396
612
|
```
|
|
397
613
|
|
|
398
614
|
Defaults: `maxAgeMs 30000` (1000–120000), `clockSkewMs 5000` (0–30000),
|
|
399
615
|
`clusterClockGuardMs 5000` (0–30000). The last is the declared maximum
|
|
400
616
|
pairwise clock difference between verifiers and replay stores, not an extra
|
|
401
617
|
freshness allowance. Maximum remaining retention at a verifier is
|
|
402
|
-
`maxAgeMs + 2 * clockSkewMs + clusterClockGuardMs` (default at most 45000 ms)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
618
|
+
`maxAgeMs + 2 * clockSkewMs + clusterClockGuardMs` (default at most 45000 ms).
|
|
619
|
+
A store clock trailing that verifier by one guard can observe at most
|
|
620
|
+
`maxAgeMs + 2 * clockSkewMs + 2 * clusterClockGuardMs` (default 50000 ms).
|
|
621
|
+
Built-in stores enforce the global 240000 ms ceiling; they receive absolute
|
|
622
|
+
deadlines, not a policy registry. Verifiers must derive the exact policy
|
|
623
|
+
deadline. Retain through the whole acceptance period; do not clamp, slide on
|
|
624
|
+
duplicate, or use a different Redis formula. Repeat the time/deadline check
|
|
625
|
+
after async provider work, immediately before reservation, and before calling
|
|
626
|
+
the handler after reservation.
|
|
627
|
+
|
|
628
|
+
**One protection space requires one time policy.** All verifiers for the same
|
|
629
|
+
`(publicOrigin, replayNamespace)`, including guards on different routes, must
|
|
630
|
+
share replay state and identical `maxAgeMs`, `clockSkewMs`, and
|
|
631
|
+
`clusterClockGuardMs`. Every accepted key ID must have identical bytes,
|
|
632
|
+
`acceptFrom`, and `acceptUntil` across those verifiers. Keep verifier/store
|
|
633
|
+
clocks within the declared pairwise guard (Redis uses server `TIME`; memory
|
|
634
|
+
and Mongo use their configured app clock). Shared capacity settings must also
|
|
635
|
+
match. A short-policy verifier can otherwise discard a nonce while a
|
|
636
|
+
long-policy verifier still accepts its proof; shared storage alone cannot
|
|
637
|
+
repair heterogeneous policies. Custom stores receive the absolute
|
|
638
|
+
`reserve({ replayKey, retainUntilMs })` deadline, not a relative TTL.
|
|
639
|
+
|
|
640
|
+
For a live policy migration, provision a **new `replayNamespace` and fresh key
|
|
641
|
+
IDs**, deploy matching signer/client and guard configuration together, and
|
|
642
|
+
keep any draining old namespace uniformly on its old policy and shared replay
|
|
643
|
+
ledger. Retain old replay state through the last possible old `retainUntilMs`
|
|
644
|
+
(including future-skew and clock-guard allowances), then retire that protection
|
|
645
|
+
space. Namespace changes require new client configuration; stale-key reload
|
|
646
|
+
alone does not change the client's pinned namespace. Do not rolling-mix policy
|
|
647
|
+
values in one space, extend the acceptance interval of a live key ID, or
|
|
648
|
+
reinterpret existing reservations under a longer window. Duplicate admission
|
|
649
|
+
never extends retention, and the built-in stores offer no migration API to
|
|
650
|
+
retroactively recover expired replay history.
|
|
408
651
|
|
|
409
652
|
Replay key: `att:v1:` plus SHA-256 of `[replayNamespace, publicOrigin,
|
|
410
653
|
nonceHex]`. It carries no instance ID, path, token, session, or key ID, so
|
|
@@ -420,6 +663,14 @@ bytes, opaque replay keys 1–256 printable ASCII bytes. `publicOrigin` is
|
|
|
420
663
|
required, static, canonical HTTP(S) (HTTPS except loopback); never derive it
|
|
421
664
|
from `Host`, `Forwarded`, or `X-Forwarded-*`.
|
|
422
665
|
|
|
666
|
+
The guard rejects missing/empty proofs as `403 ATTESTATION_MISSING` and
|
|
667
|
+
duplicate, comma-joined, oversized, or noncanonical proofs as
|
|
668
|
+
`403 ATTESTATION_MALFORMED` before provider/replay/handler work, even if the
|
|
669
|
+
provider is unavailable. The 1024-byte envelope cap is checked before UTF-8
|
|
670
|
+
allocation. Canonical envelopes cannot start with a UTF-8 BOM. Raw duplicate
|
|
671
|
+
`Content-Type` fields are malformed even when identical; commas inside a
|
|
672
|
+
single field are preserved.
|
|
673
|
+
|
|
423
674
|
## Errors
|
|
424
675
|
|
|
425
676
|
All verifier failures use fixed `{ code, message }` JSON with
|
|
@@ -445,7 +696,11 @@ adds `X-Attestation-Error: stale-key`.
|
|
|
445
696
|
|
|
446
697
|
Unknown or inactive well-formed key IDs select `STALE_KEY`; a known active
|
|
447
698
|
key with an elapsed timestamp selects `EXPIRED`. Provider snapshot failure
|
|
448
|
-
selects `KEYS_UNAVAILABLE`, never an unknown-key result.
|
|
699
|
+
selects `KEYS_UNAVAILABLE`, never an unknown-key result. This includes provider
|
|
700
|
+
throws/rejections (even typed stale/expired errors), timeouts, malformed
|
|
701
|
+
snapshots, and throwing snapshot accessors: all use the fixed 503 message and
|
|
702
|
+
never carry the stale marker. Clock, body, and replay-store failures keep their
|
|
703
|
+
separate codes above.
|
|
449
704
|
|
|
450
705
|
## Rotation runbook
|
|
451
706
|
|
|
@@ -459,10 +714,9 @@ Finite acceptance is application-configured, never automatically
|
|
|
459
714
|
|
|
460
715
|
Do not make one instance the authoritative random-key generator for an
|
|
461
716
|
uncoordinated cluster. Before dropping assets needed by published metadata,
|
|
462
|
-
account for the one bounded refetch-on-retirement client behavior.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
profile to expire.
|
|
717
|
+
account for the one bounded refetch-on-retirement client behavior. For policy
|
|
718
|
+
changes follow the protection-space migration above; extending old key
|
|
719
|
+
deadlines or mixing lifetime/skew/guard values is not a rotation strategy.
|
|
466
720
|
|
|
467
721
|
## Runtime requirements
|
|
468
722
|
|
|
@@ -477,17 +731,48 @@ profile to expire.
|
|
|
477
731
|
expose `X-Attestation-Error` so the wrapper can see the retry marker; allow
|
|
478
732
|
credentialed origins only when explicitly using `credentials: 'include'`.
|
|
479
733
|
Native module imports require `script-src` plus CORS for the public module
|
|
480
|
-
URLs
|
|
481
|
-
|
|
482
|
-
|
|
734
|
+
URLs, and `connect-src` must allow metadata/API fetches. Configure actual
|
|
735
|
+
origins/methods and let OPTIONS preflight reach the CORS handler before the
|
|
736
|
+
attestation guard. Missing marker exposure leaves the first 403 visible but
|
|
737
|
+
prevents stale-key retry. Discovery fetch uses `cache: no-store`,
|
|
738
|
+
`credentials: omit`, `redirect: error`; native dynamic imports follow browser
|
|
739
|
+
module-fetch/cache rules, not those injected fetch options. Serve both key
|
|
740
|
+
and runtime modules with suitable CORS, permit their origins in CSP, and do
|
|
741
|
+
not redirect modules to other origins.
|
|
742
|
+
|
|
743
|
+
## Compatibility notes
|
|
744
|
+
|
|
745
|
+
The v1 MAC tuple, named exports, two entrypoints, and absolute `retainUntilMs`
|
|
746
|
+
reservation API are unchanged. Consumers of earlier, more permissive inputs
|
|
747
|
+
should account for these tightened contracts:
|
|
748
|
+
|
|
749
|
+
- Proof framing is checked before providers; BOM envelopes and duplicate raw
|
|
750
|
+
`Content-Type` fields reject. Provider failures always produce sanitized
|
|
751
|
+
503 responses rather than stale-key challenges.
|
|
752
|
+
- Metadata fetch substitutes must expose a readable byte stream. Standalone
|
|
753
|
+
discovery's 5-second body-read deadline differs from the configurable
|
|
754
|
+
whole-client-load deadline described above.
|
|
755
|
+
- Inherited Request bodies remain reusable; explicit non-null bodies override,
|
|
756
|
+
`null` inherits, and mode/body getters are sampled once in that order. Failed
|
|
757
|
+
retry preparation returns readable first-response data; caller abort rejects.
|
|
758
|
+
- `no-cors` and browser-forbidden proof fields fail locally, including under
|
|
759
|
+
injected/Node transports. The final Request must retain the exact proof.
|
|
760
|
+
- Replacement loads coalesce challenges across older key generations. A changed
|
|
761
|
+
validated module URL permits the single same-key retirement refetch.
|
|
762
|
+
- Root asset mounts publish single-slash paths. Discovery rechecks the current
|
|
763
|
+
key after provider waits instead of publishing a key that became inactive.
|
|
483
764
|
|
|
484
765
|
## Verification commands
|
|
485
766
|
|
|
486
|
-
|
|
767
|
+
Contributor checks, from the repository root after dependency installation.
|
|
768
|
+
Build first: declaration checks and runtime/consumer lanes read the built
|
|
769
|
+
output and do not rebuild it implicitly.
|
|
487
770
|
|
|
488
771
|
```sh
|
|
489
772
|
pnpm --filter @web-ts-toolkit/express-request-attestation... build
|
|
490
773
|
pnpm --filter @web-ts-toolkit/express-request-attestation typecheck
|
|
774
|
+
# Included in typecheck; this runs only the built-declaration fixtures:
|
|
775
|
+
pnpm --filter @web-ts-toolkit/express-request-attestation typecheck:declarations
|
|
491
776
|
pnpm --filter @web-ts-toolkit/express-request-attestation test:node
|
|
492
777
|
pnpm --filter @web-ts-toolkit/express-request-attestation test:redis
|
|
493
778
|
pnpm --filter @web-ts-toolkit/express-request-attestation test:mongo
|
|
@@ -497,8 +782,19 @@ pnpm --filter @web-ts-toolkit/express-request-attestation exec npm pack --dry-ru
|
|
|
497
782
|
pnpm --filter @web-ts-toolkit/express-request-attestation test
|
|
498
783
|
```
|
|
499
784
|
|
|
500
|
-
`
|
|
501
|
-
|
|
785
|
+
`typecheck` runs source and browser-only source checks, then
|
|
786
|
+
`typecheck:declarations` runs all three configs in `test-decl-consumer/`:
|
|
787
|
+
`tsconfig-nodenext.json` (both entries under `.mts` import and `.cts` require),
|
|
788
|
+
`tsconfig-bundler.json`, and `tsconfig-browser.json` (`types: []`). All use
|
|
789
|
+
public package imports against built declarations with `strict: true` and
|
|
790
|
+
`skipLibCheck: false`; no source path mapping stands in for the package.
|
|
791
|
+
`test:packed-consumer` installs the release-shaped tarball in isolated backend
|
|
792
|
+
and browser projects, compiles actual Express handler assignments and the
|
|
793
|
+
installed README examples, and checks CJS/ESM and package inventories.
|
|
794
|
+
|
|
795
|
+
The filtered package `test` command builds once, then runs Node, live Redis,
|
|
796
|
+
live MongoDB, browser, and tarball lanes serially; never overlap builds writing
|
|
797
|
+
the same output directory. Prerequisites:
|
|
502
798
|
Docker for isolated Redis 6.2/7.2 tests, a MongoDB replica-set binary
|
|
503
799
|
download (registry/network) for the live `test:mongo` lane, installed
|
|
504
800
|
Playwright Chromium, and registry access for fresh tarball consumers. A
|