@web-ts-toolkit/express-request-attestation 0.48.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/LICENSE +201 -0
- package/README.md +505 -0
- package/index.d.mts +712 -0
- package/index.d.ts +712 -0
- package/index.js +2881 -0
- package/index.mjs +2850 -0
- package/package.json +64 -0
- package/signer.d.mts +351 -0
- package/signer.d.ts +351 -0
- package/signer.js +2064 -0
- package/signer.mjs +2041 -0
- package/time-policy-DlER-7rf.d.mts +385 -0
- package/time-policy-DlER-7rf.d.ts +385 -0
package/README.md
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
# `@web-ts-toolkit/express-request-attestation`
|
|
2
|
+
|
|
3
|
+
Versioned per-request HMAC signing and replay detection for Express APIs,
|
|
4
|
+
with a browser-safe signer entry. One installable package, two public
|
|
5
|
+
entries: `.` for Express, `./signer` for the browser.
|
|
6
|
+
|
|
7
|
+
## Guarantee and limitation
|
|
8
|
+
|
|
9
|
+
A verifier checks that a request's signed fields match a currently accepted
|
|
10
|
+
key, that its timestamp is within policy, and that its nonce has not already
|
|
11
|
+
been admitted in the same protection space.
|
|
12
|
+
|
|
13
|
+
**A key delivered in public JavaScript is accessible to every caller.**
|
|
14
|
+
Encoding, minification, and rotation do not make it a confidential
|
|
15
|
+
authentication credential: any script can fetch the current material and
|
|
16
|
+
generate fresh valid signatures without a browser. This package therefore
|
|
17
|
+
provides request-format consistency and detection of repeated signed
|
|
18
|
+
submissions while replay state survives. It does **not** establish
|
|
19
|
+
browser/device authenticity, a human user, session ownership, application
|
|
20
|
+
authorization, or reliable bot prevention. OIDC/session/DPoP validation
|
|
21
|
+
continues to establish its own authentication boundary, and the public HMAC
|
|
22
|
+
key must never be reused for JWT signing, OIDC client secrets, cookies,
|
|
23
|
+
CSRF secrets, DPoP challenges, or other confidential server operations.
|
|
24
|
+
|
|
25
|
+
Rotation limits how long old material is accepted; it does not stop a caller
|
|
26
|
+
from downloading the next version. Nonce admission does not prevent abuse
|
|
27
|
+
using newly generated nonces. Replay admission is not business-operation
|
|
28
|
+
idempotency or exactly-once delivery; durable idempotency is
|
|
29
|
+
application-owned. Never release a successful reservation after handler
|
|
30
|
+
failure or a lost response; a fresh proof is required on a new attempt.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
Backend consumers install Express explicitly (optional peer `>=5`); the
|
|
35
|
+
signer entry never needs it. Redis and MongoDB are caller-injected through
|
|
36
|
+
structural types; installing each driver is only needed when using that
|
|
37
|
+
store.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
# Backend (Express >= 5 required at runtime)
|
|
41
|
+
pnpm add @web-ts-toolkit/express-request-attestation express
|
|
42
|
+
# Browser-only (no Express needed)
|
|
43
|
+
pnpm add @web-ts-toolkit/express-request-attestation
|
|
44
|
+
# With shared Redis replay state (backend only)
|
|
45
|
+
pnpm add redis
|
|
46
|
+
# With shared MongoDB replay state (backend only, replica set required)
|
|
47
|
+
pnpm add mongodb
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Entry imports
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// Server: middleware, body capture, key providers, stores, asset router.
|
|
54
|
+
import {
|
|
55
|
+
createAttestationBodyCapture,
|
|
56
|
+
createMemoryAttestationStore,
|
|
57
|
+
createMongoAttestationStore,
|
|
58
|
+
createRedisAttestationStore,
|
|
59
|
+
createRequestAttestationMiddleware,
|
|
60
|
+
createSignerBundleRouter,
|
|
61
|
+
createStaticKeyProvider,
|
|
62
|
+
generateAttestationKey,
|
|
63
|
+
} from '@web-ts-toolkit/express-request-attestation';
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// Browser: WebCrypto signer, module client, prepared fetch. Never imports
|
|
68
|
+
// Express, Redis, MongoDB, Buffer, or node:*.
|
|
69
|
+
import {
|
|
70
|
+
createRequestSigner,
|
|
71
|
+
createSignerClient,
|
|
72
|
+
fetchWithAttestation,
|
|
73
|
+
} from '@web-ts-toolkit/express-request-attestation/signer';
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Only `.` and `./signer` are public. No default exports and no deep
|
|
77
|
+
`src/*` or `dist/*` imports.
|
|
78
|
+
|
|
79
|
+
### Backend quickstart (guard + capture + assets)
|
|
80
|
+
|
|
81
|
+
Complete parser/capture/guard/asset wiring. Mount the guard on selected API
|
|
82
|
+
routes only; asset, preflight, OIDC navigation, callback, and backchannel
|
|
83
|
+
routes stay reachable under their own policies.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import express from 'express';
|
|
87
|
+
import {
|
|
88
|
+
createAttestationBodyCapture,
|
|
89
|
+
createMemoryAttestationStore,
|
|
90
|
+
createRequestAttestationMiddleware,
|
|
91
|
+
createSignerBundleRouter,
|
|
92
|
+
createStaticKeyProvider,
|
|
93
|
+
generateAttestationKey,
|
|
94
|
+
} from '@web-ts-toolkit/express-request-attestation';
|
|
95
|
+
|
|
96
|
+
const publicOrigin = 'https://api.example.com';
|
|
97
|
+
const replayNamespace = 'my-app-prod';
|
|
98
|
+
const maxBodyBytes = 1024 * 1024;
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
const entry = generateAttestationKey('v1-20261002-01', {
|
|
101
|
+
acceptFrom: now - 60_000,
|
|
102
|
+
acceptUntil: now + 3_600_000,
|
|
103
|
+
});
|
|
104
|
+
const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
|
|
105
|
+
const store = createMemoryAttestationStore();
|
|
106
|
+
const capture = createAttestationBodyCapture({ maxBodyBytes });
|
|
107
|
+
const guard = createRequestAttestationMiddleware({
|
|
108
|
+
publicOrigin,
|
|
109
|
+
replayNamespace,
|
|
110
|
+
keyProvider,
|
|
111
|
+
store,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const app = express();
|
|
115
|
+
app.use('/attestation', createSignerBundleRouter({ publicOrigin, replayNamespace, keyProvider }));
|
|
116
|
+
app.use(express.json({ limit: maxBodyBytes, verify: capture.verify as never, inflate: false }));
|
|
117
|
+
app.use(capture.errorHandler as never);
|
|
118
|
+
app.post('/api/submit', guard as never, (_req, res) => {
|
|
119
|
+
res.json({ ok: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export { app };
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Parser and wrapper limits must match (`maxBodyBytes`, default 1 MiB, hard
|
|
126
|
+
cap 16 MiB). Always use `inflate: false` so signed bytes and captured bytes
|
|
127
|
+
are the same representation. V1 accepts identity or absent
|
|
128
|
+
`Content-Encoding` only; compression is rejected. An actually empty request
|
|
129
|
+
uses SHA-256 of zero bytes. A nonempty body whose parser failed to capture
|
|
130
|
+
it is `500 ATTESTATION_BODY_CAPTURE_REQUIRED`, not the empty digest.
|
|
131
|
+
|
|
132
|
+
### Public keyring provisioning
|
|
133
|
+
|
|
134
|
+
One public keyring example. Provisioning is application-owned (cron, KMS, or
|
|
135
|
+
config); the library performs explicit atomic replacement with no polling
|
|
136
|
+
timer and no hardcoded grace policy.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { createRotatingKeyProvider, generateAttestationKey } from '@web-ts-toolkit/express-request-attestation';
|
|
140
|
+
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const oldEntry = generateAttestationKey('v1-20261002-01', {
|
|
143
|
+
acceptFrom: now - 60_000,
|
|
144
|
+
acceptUntil: now + 3_600_000,
|
|
145
|
+
});
|
|
146
|
+
const provider = createRotatingKeyProvider({ currentKeyId: oldEntry.keyId, keys: [oldEntry] });
|
|
147
|
+
|
|
148
|
+
const nextEntry = generateAttestationKey('v1-20261003-01', {
|
|
149
|
+
acceptFrom: now,
|
|
150
|
+
acceptUntil: now + 3_600_000,
|
|
151
|
+
});
|
|
152
|
+
provider.replace({ currentKeyId: nextEntry.keyId, keys: [oldEntry, nextEntry] });
|
|
153
|
+
|
|
154
|
+
export { provider };
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Snapshots are owned and copy-isolated (max 16 keys). `currentKeyId` must
|
|
158
|
+
name a present entry. Key IDs are unique across provisioning history:
|
|
159
|
+
providers reject remapping a retained ID to different bytes or a different
|
|
160
|
+
acceptance interval. Generate unique version/random IDs and never reintroduce
|
|
161
|
+
a retired ID with different material. Rejected replacements leave the previous
|
|
162
|
+
valid snapshot intact.
|
|
163
|
+
|
|
164
|
+
### Redis store wiring
|
|
165
|
+
|
|
166
|
+
Structural injection only. The library never creates, connects, or closes
|
|
167
|
+
clients and never imports a Redis driver.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { createClient } from 'redis';
|
|
171
|
+
import {
|
|
172
|
+
createRedisAttestationStore,
|
|
173
|
+
createStaticKeyProvider,
|
|
174
|
+
generateAttestationKey,
|
|
175
|
+
} from '@web-ts-toolkit/express-request-attestation';
|
|
176
|
+
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const entry = generateAttestationKey('v1-redis-01', {
|
|
179
|
+
acceptFrom: now - 60_000,
|
|
180
|
+
acceptUntil: now + 3_600_000,
|
|
181
|
+
});
|
|
182
|
+
const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
|
|
183
|
+
const client = createClient({ url: 'redis://127.0.0.1:6379' });
|
|
184
|
+
await client.connect();
|
|
185
|
+
const store = createRedisAttestationStore({ client, keyPrefix: 'wtt-attestation', maxEntries: 50000 });
|
|
186
|
+
|
|
187
|
+
export { client, keyProvider, store };
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
One same-slot sorted set per shared prefix holds opaque replay keys with
|
|
191
|
+
absolute `retainUntil` scores. All clients sharing a prefix must configure
|
|
192
|
+
identical `maxEntries`. Size the ledger as unique accepted requests per second
|
|
193
|
+
times maximum retention plus headroom; actual Redis memory and throughput
|
|
194
|
+
require measurement. Use a dedicated `noeviction` deployment when relying on
|
|
195
|
+
window-wide admission history: eviction, restore, state loss, or failover can
|
|
196
|
+
erase replay reservations. Client lifecycle stays caller-owned.
|
|
197
|
+
|
|
198
|
+
### Mongo store wiring
|
|
199
|
+
|
|
200
|
+
Structural injection only. The library never creates, connects, or closes
|
|
201
|
+
clients and never imports a MongoDB driver. Production deployments require
|
|
202
|
+
a replica set (transactions).
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { MongoClient } from 'mongodb';
|
|
206
|
+
import {
|
|
207
|
+
createMongoAttestationStore,
|
|
208
|
+
createStaticKeyProvider,
|
|
209
|
+
generateAttestationKey,
|
|
210
|
+
} from '@web-ts-toolkit/express-request-attestation';
|
|
211
|
+
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
const entry = generateAttestationKey('v1-mongo-01', {
|
|
214
|
+
acceptFrom: now - 60_000,
|
|
215
|
+
acceptUntil: now + 3_600_000,
|
|
216
|
+
});
|
|
217
|
+
const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
|
|
218
|
+
const client = new MongoClient('mongodb://127.0.0.1:27017/?replicaSet=rs0');
|
|
219
|
+
await client.connect();
|
|
220
|
+
const store = createMongoAttestationStore({
|
|
221
|
+
db: client.db('app-attestation'),
|
|
222
|
+
proofsCollectionName: 'attestation_proofs',
|
|
223
|
+
capacityCollectionName: 'attestation_replay_capacity',
|
|
224
|
+
maxEntries: 50000,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
export { client, keyProvider, store };
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
One proof collection with unique `_id`s plus a separate non-TTL
|
|
231
|
+
capacity/accounting collection with one shared capacity row holds opaque
|
|
232
|
+
replay keys with absolute `retainUntilMs` deadlines. Each admission runs one
|
|
233
|
+
snapshot transaction: duplicate check, bounded expiry reclaim (the requested
|
|
234
|
+
expired entry plus at most 64 expired entries), capacity check, then insert.
|
|
235
|
+
Duplicates return `'duplicate'` even when full and never extend expiry; no
|
|
236
|
+
live entry is ever evicted. Do not rely on a TTL index for correctness;
|
|
237
|
+
create a non-TTL index on `retainUntilMs` (for example
|
|
238
|
+
`{ kind: 1, retainUntilMs: 1 }` on the capacity collection) and never add a
|
|
239
|
+
TTL index to the accounting collection.
|
|
240
|
+
|
|
241
|
+
All clients sharing one database/collections must configure identical
|
|
242
|
+
`maxEntries` (default 50000, positive safe integer) and synchronized clocks
|
|
243
|
+
within the configured `clusterClockGuardMs`; an invalid clock fails closed
|
|
244
|
+
without touching the database. Global retention is capped at 240000 ms
|
|
245
|
+
without truncation. Size the ledger as unique accepted requests per second
|
|
246
|
+
times maximum retention plus headroom; each admission costs one snapshot
|
|
247
|
+
transaction and actual MongoDB memory and throughput require measurement on
|
|
248
|
+
your deployment. Capacity throws the shared `AttestationCapacityError`;
|
|
249
|
+
transport, topology, transaction, and malformed-row failures throw the narrow
|
|
250
|
+
`AttestationMongoStoreError` mapped to `ATTESTATION_REPLAY_UNAVAILABLE`;
|
|
251
|
+
malformed inputs throw `AttestationProtocolError` without touching the
|
|
252
|
+
database. Client lifecycle stays caller-owned (connect/close the
|
|
253
|
+
`MongoClient` in application code).
|
|
254
|
+
|
|
255
|
+
When to choose a store: memory for a single instance with no shared state;
|
|
256
|
+
Redis for high-throughput shared replay with server `TIME`; MongoDB when
|
|
257
|
+
deployments already run a replica set and prefer shared replay without
|
|
258
|
+
operating Redis, accepting one transaction per admission.
|
|
259
|
+
|
|
260
|
+
### Bearer-auth composition
|
|
261
|
+
|
|
262
|
+
Tested composition. The guard never sets `req.auth`, never enrolls or
|
|
263
|
+
validates a DPoP binding, and never rewrites authentication. Every auth retry
|
|
264
|
+
re-signs via a fresh wrapper call.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import express from 'express';
|
|
268
|
+
import {
|
|
269
|
+
createAttestationBodyCapture,
|
|
270
|
+
createMemoryAttestationStore,
|
|
271
|
+
createRequestAttestationMiddleware,
|
|
272
|
+
createStaticKeyProvider,
|
|
273
|
+
generateAttestationKey,
|
|
274
|
+
} from '@web-ts-toolkit/express-request-attestation';
|
|
275
|
+
|
|
276
|
+
const publicOrigin = 'https://api.example.com';
|
|
277
|
+
const replayNamespace = 'my-app-prod';
|
|
278
|
+
const maxBodyBytes = 1024 * 1024;
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
const entry = generateAttestationKey('v1-bearer-01', {
|
|
281
|
+
acceptFrom: now - 60_000,
|
|
282
|
+
acceptUntil: now + 3_600_000,
|
|
283
|
+
});
|
|
284
|
+
const keyProvider = createStaticKeyProvider({ currentKeyId: entry.keyId, keys: [entry] });
|
|
285
|
+
const store = createMemoryAttestationStore();
|
|
286
|
+
const capture = createAttestationBodyCapture({ maxBodyBytes });
|
|
287
|
+
const guard = createRequestAttestationMiddleware({ publicOrigin, replayNamespace, keyProvider, store });
|
|
288
|
+
|
|
289
|
+
function requireBearerAuth(req: express.Request, res: express.Response, next: express.NextFunction): void {
|
|
290
|
+
if (req.headers.authorization !== 'Bearer good-token') {
|
|
291
|
+
res.status(401).json({ code: 'UNAUTHORIZED' });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
next();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const app = express();
|
|
298
|
+
app.use(express.json({ limit: maxBodyBytes, verify: capture.verify as never, inflate: false }));
|
|
299
|
+
app.use(capture.errorHandler as never);
|
|
300
|
+
app.post('/api/submit', guard as never, requireBearerAuth, (_req, res) => {
|
|
301
|
+
res.json({ ok: true });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
export { app, requireBearerAuth };
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Mount an OIDC vault router once at its own base path (for example
|
|
308
|
+
`basePath: '/auth/oidc'`); never double-prefix. The guard applies only to
|
|
309
|
+
selected API routes. DPoP composition uses existing public vault APIs through
|
|
310
|
+
the same selected-route pattern. Fingerprint or recognition collection is not
|
|
311
|
+
implemented by this package.
|
|
312
|
+
|
|
313
|
+
### Frontend quickstart (signer client + prepared fetch)
|
|
314
|
+
|
|
315
|
+
Same-origin default. Caller JSON objects are not valid Fetch bodies: callers
|
|
316
|
+
stringify first. The wrapper materializes those exact bytes once, signs them,
|
|
317
|
+
and reuses them verbatim for the single stale-key retry (multipart
|
|
318
|
+
boundaries preserved).
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
import { createSignerClient, fetchWithAttestation } from '@web-ts-toolkit/express-request-attestation/signer';
|
|
322
|
+
|
|
323
|
+
const apiOrigin = 'https://api.example.com';
|
|
324
|
+
const replayNamespace = 'my-app-prod';
|
|
325
|
+
const signerClient = createSignerClient({
|
|
326
|
+
metadataUrl: `${apiOrigin}/attestation/signer-meta`,
|
|
327
|
+
apiOrigin,
|
|
328
|
+
replayNamespace,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const response = await fetchWithAttestation(
|
|
332
|
+
`${apiOrigin}/api/submit`,
|
|
333
|
+
{
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: { 'content-type': 'application/json' },
|
|
336
|
+
body: JSON.stringify({ hello: 'world' }),
|
|
337
|
+
},
|
|
338
|
+
{ signerClient, apiOrigin, replayNamespace },
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
export { response, signerClient };
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Only the configured `apiOrigin` is signed; external URLs fail locally without
|
|
345
|
+
sending `Authorization` or a signature. Explicit `credentials` are preserved
|
|
346
|
+
(native default `same-origin`, never defaulted to `include`). Signed attempts
|
|
347
|
+
use `redirect: 'manual'` and never forward a signature. Exactly one automatic
|
|
348
|
+
retry, only on `403` plus `X-Attestation-Error: stale-key`; expired, future,
|
|
349
|
+
replay, invalid proofs, `5xx`, auth failures, redirects, and network
|
|
350
|
+
uncertainty never retry. A second stale challenge stops. Returned responses
|
|
351
|
+
remain readable and retries never reuse the old envelope.
|
|
352
|
+
|
|
353
|
+
Body profile: finite `BodyInit` plus replayable `Request`s within
|
|
354
|
+
`maxBodyBytes` (default 1 MiB, hard cap 16 MiB) and a bounded read deadline
|
|
355
|
+
(default 5000 ms). `FormData` is serialized through a `Request` once;
|
|
356
|
+
streaming uploads and non-identity `Content-Encoding` are rejected locally.
|
|
357
|
+
|
|
358
|
+
## Signer assets and caching
|
|
359
|
+
|
|
360
|
+
`createSignerBundleRouter` returns a relative router mounted by the
|
|
361
|
+
application at a configured base path (default `/attestation`). Routes:
|
|
362
|
+
`GET /signer-meta`, `GET /signer.<contenthash>.mjs`, and
|
|
363
|
+
`GET /runtime.<contenthash>.mjs`. Metadata is bounded JSON
|
|
364
|
+
(`{ version: 1, keyId, signerUrl, publicOrigin, replayNamespace }`) with
|
|
365
|
+
`no-store`; it serves the current active key only and contains no
|
|
366
|
+
session or user credential.
|
|
367
|
+
|
|
368
|
+
The key-specific ESM wrapper imports `createRequestSigner` from the
|
|
369
|
+
content-addressed runtime module and exports `signer`. The runtime is the
|
|
370
|
+
packaged browser `signer.mjs`, so route assets and the npm browser entry
|
|
371
|
+
share the codec and signer source. The 64-hex content hash identifies exact
|
|
372
|
+
immutable bytes; never serve different content from the same URL. Assets are
|
|
373
|
+
public, served as JavaScript with `nosniff`, cacheable with `immutable`
|
|
374
|
+
(runtime cap 512 KiB, key module cap 8 KiB). Unknown hashes return `404`;
|
|
375
|
+
syntactically valid but not retained hashes return `410`; neither redirects
|
|
376
|
+
to latest. CORS and CSP stay application-configured.
|
|
377
|
+
|
|
378
|
+
`fetchSignerBundle` uses bounded metadata (4096-byte cap) with noncached,
|
|
379
|
+
credential-omitting, redirect-rejecting defaults, validates origin, namespace,
|
|
380
|
+
and version, and loads via native dynamic `import()` (default loader) for a
|
|
381
|
+
URL on the configured API origin. `createSignerClient` owns at most one
|
|
382
|
+
active signer and one in-flight load (single-flight, no cross-tab claim) with
|
|
383
|
+
generation-aware `invalidate(observedKeyId)` and terminal `dispose()`. A
|
|
384
|
+
metadata-to-module retirement race may refetch discovery once; repeated
|
|
385
|
+
failure stops. Native import caching cannot be erased by invalidation; do not
|
|
386
|
+
claim keys live for only milliseconds.
|
|
387
|
+
|
|
388
|
+
## Time policy
|
|
389
|
+
|
|
390
|
+
```text
|
|
391
|
+
reject future proof if timestampMs > nowMs + clockSkewMs
|
|
392
|
+
timeDeadline = timestampMs + maxAgeMs + clockSkewMs
|
|
393
|
+
proofDeadline = min(timeDeadline, acceptedKey.acceptUntil)
|
|
394
|
+
reject when nowMs >= proofDeadline or acceptedKey is not active at nowMs
|
|
395
|
+
retainUntil = proofDeadline + clusterClockGuardMs
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Defaults: `maxAgeMs 30000` (1000–120000), `clockSkewMs 5000` (0–30000),
|
|
399
|
+
`clusterClockGuardMs 5000` (0–30000). The last is the declared maximum
|
|
400
|
+
pairwise clock difference between verifiers and replay stores, not an extra
|
|
401
|
+
freshness allowance. Maximum remaining retention at a verifier is
|
|
402
|
+
`maxAgeMs + 2 * clockSkewMs + clusterClockGuardMs` (default at most 45000 ms);
|
|
403
|
+
the store admission bound adds one more guard (default at most 50000 ms,
|
|
404
|
+
global cap 240000 ms). Retain through the whole acceptance period; do not
|
|
405
|
+
clamp, slide on duplicate, or use a different Redis formula. Repeat the
|
|
406
|
+
time and deadline check after async provider work, immediately before
|
|
407
|
+
reservation, and before calling the handler after reservation.
|
|
408
|
+
|
|
409
|
+
Replay key: `att:v1:` plus SHA-256 of `[replayNamespace, publicOrigin,
|
|
410
|
+
nonceHex]`. It carries no instance ID, path, token, session, or key ID, so
|
|
411
|
+
nonce single-use spans accepted rotations in one protection space. Distinct
|
|
412
|
+
applications use distinct namespaces or origins.
|
|
413
|
+
|
|
414
|
+
Default header: `x-client-transaction-id` (valid HTTP token, no auth, cookie,
|
|
415
|
+
host, origin, body-metadata, or response-marker collision). One incoming
|
|
416
|
+
signature field only; duplicates and comma-joined presentations are rejected.
|
|
417
|
+
Limits: encoded ID 1024 bytes, canonical request target 8192 bytes, content
|
|
418
|
+
type 256 bytes, namespace `[A-Za-z0-9_-]{1,128}`, canonical public origin 512
|
|
419
|
+
bytes, opaque replay keys 1–256 printable ASCII bytes. `publicOrigin` is
|
|
420
|
+
required, static, canonical HTTP(S) (HTTPS except loopback); never derive it
|
|
421
|
+
from `Host`, `Forwarded`, or `X-Forwarded-*`.
|
|
422
|
+
|
|
423
|
+
## Errors
|
|
424
|
+
|
|
425
|
+
All verifier failures use fixed `{ code, message }` JSON with
|
|
426
|
+
`Cache-Control: no-store`. No raw key, nonce, signature, body, provider
|
|
427
|
+
error, or requested key ID is echoed. Only a pre-handler stale-key rejection
|
|
428
|
+
adds `X-Attestation-Error: stale-key`.
|
|
429
|
+
|
|
430
|
+
| HTTP | Code | Fixed message |
|
|
431
|
+
| ---- | ----------------------------------- | ----------------------------------------------------------------- |
|
|
432
|
+
| 403 | `ATTESTATION_MISSING` | Request signature is required. |
|
|
433
|
+
| 403 | `ATTESTATION_MALFORMED` | Request signature is invalid. |
|
|
434
|
+
| 403 | `ATTESTATION_STALE_KEY` | Request signing key is no longer accepted. |
|
|
435
|
+
| 403 | `ATTESTATION_EXPIRED` | Request signature has expired. |
|
|
436
|
+
| 403 | `ATTESTATION_FUTURE` | Request signature timestamp is ahead of the allowed clock window. |
|
|
437
|
+
| 403 | `ATTESTATION_INVALID_SIGNATURE` | Request signature does not match. |
|
|
438
|
+
| 403 | `ATTESTATION_REPLAY` | Request signature was already used. |
|
|
439
|
+
| 413 | `ATTESTATION_BODY_TOO_LARGE` | Request body exceeds the configured byte limit. |
|
|
440
|
+
| 415 | `ATTESTATION_UNSUPPORTED_ENCODING` | Request body encoding is unsupported. |
|
|
441
|
+
| 500 | `ATTESTATION_BODY_CAPTURE_REQUIRED` | Request body capture is not configured. |
|
|
442
|
+
| 500 | `ATTESTATION_INTERNAL_ERROR` | Request signature verification failed. |
|
|
443
|
+
| 503 | `ATTESTATION_KEYS_UNAVAILABLE` | Request signing keys are unavailable. |
|
|
444
|
+
| 503 | `ATTESTATION_REPLAY_UNAVAILABLE` | Request replay protection is unavailable. |
|
|
445
|
+
|
|
446
|
+
Unknown or inactive well-formed key IDs select `STALE_KEY`; a known active
|
|
447
|
+
key with an elapsed timestamp selects `EXPIRED`. Provider snapshot failure
|
|
448
|
+
selects `KEYS_UNAVAILABLE`, never an unknown-key result.
|
|
449
|
+
|
|
450
|
+
## Rotation runbook
|
|
451
|
+
|
|
452
|
+
Finite acceptance is application-configured, never automatically
|
|
453
|
+
`2 × rotation interval`:
|
|
454
|
+
|
|
455
|
+
1. Prepare new material on **all** verifiers.
|
|
456
|
+
2. Publish current metadata and module.
|
|
457
|
+
3. Retain old material to its explicit deadline for overlap.
|
|
458
|
+
4. Retire only after overlap requirements are met.
|
|
459
|
+
|
|
460
|
+
Do not make one instance the authoritative random-key generator for an
|
|
461
|
+
uncoordinated cluster. Before dropping assets needed by published metadata,
|
|
462
|
+
account for the one bounded refetch-on-retirement client behavior. Do not
|
|
463
|
+
increase lifetime, skew, or guard for already-live proofs without first
|
|
464
|
+
retaining their replay state through the longer window or waiting for the old
|
|
465
|
+
profile to expire.
|
|
466
|
+
|
|
467
|
+
## Runtime requirements
|
|
468
|
+
|
|
469
|
+
- **Node** `>= 22` (see `engines`).
|
|
470
|
+
- **Browser**: a secure context with `TextEncoder`/`TextDecoder`,
|
|
471
|
+
`crypto.subtle` (HMAC-SHA-256, non-extractable keys), and
|
|
472
|
+
`crypto.getRandomValues`, feature-detected on use. Real Chromium is
|
|
473
|
+
exercised in `test:browser`; jsdom alone is not evidence. No specific
|
|
474
|
+
historical browser versions are claimed.
|
|
475
|
+
- **CORS/CSP** (application-configured): allow the configured signature field
|
|
476
|
+
plus actual `Content-Type`, `Authorization`, or DPoP headers as needed;
|
|
477
|
+
expose `X-Attestation-Error` so the wrapper can see the retry marker; allow
|
|
478
|
+
credentialed origins only when explicitly using `credentials: 'include'`.
|
|
479
|
+
Native module imports require `script-src` plus CORS for the public module
|
|
480
|
+
URLs. Same-origin uses the native default; cross-origin deployments
|
|
481
|
+
explicitly configure CORS for metadata and module fetches (which use
|
|
482
|
+
`cache: no-store`, `credentials: omit`, `redirect: error`).
|
|
483
|
+
|
|
484
|
+
## Verification commands
|
|
485
|
+
|
|
486
|
+
From the repository root:
|
|
487
|
+
|
|
488
|
+
```sh
|
|
489
|
+
pnpm --filter @web-ts-toolkit/express-request-attestation... build
|
|
490
|
+
pnpm --filter @web-ts-toolkit/express-request-attestation typecheck
|
|
491
|
+
pnpm --filter @web-ts-toolkit/express-request-attestation test:node
|
|
492
|
+
pnpm --filter @web-ts-toolkit/express-request-attestation test:redis
|
|
493
|
+
pnpm --filter @web-ts-toolkit/express-request-attestation test:mongo
|
|
494
|
+
pnpm --filter @web-ts-toolkit/express-request-attestation test:browser
|
|
495
|
+
pnpm --filter @web-ts-toolkit/express-request-attestation test:packed-consumer
|
|
496
|
+
pnpm --filter @web-ts-toolkit/express-request-attestation exec npm pack --dry-run --json
|
|
497
|
+
pnpm --filter @web-ts-toolkit/express-request-attestation test
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
`pnpm test` builds once, then runs Node, live Redis, live MongoDB, browser,
|
|
501
|
+
and tarball lanes serially (AGENTS.md serial-build rule). Prerequisites:
|
|
502
|
+
Docker for isolated Redis 6.2/7.2 tests, a MongoDB replica-set binary
|
|
503
|
+
download (registry/network) for the live `test:mongo` lane, installed
|
|
504
|
+
Playwright Chromium, and registry access for fresh tarball consumers. A
|
|
505
|
+
missing prerequisite blocks that lane instead of silently passing it.
|