@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.
@@ -0,0 +1,385 @@
1
+ /**
2
+ * ATT-01 shared browser-safe protocol DTOs, constants, and error type.
3
+ *
4
+ * This module is imported by BOTH public entries (`.` and `./signer`), so it
5
+ * must stay free of Node built-ins, Express/Redis types, `Buffer`, `NodeJS`,
6
+ * and `node:*` imports. See `tsconfig.signer-browser.json`, which typechecks
7
+ * the signer closure with `types: []` to enforce that boundary.
8
+ *
9
+ * Protocol summary (task section 4.2):
10
+ *
11
+ * ```text
12
+ * nonceHex = lowercase hex of 16 cryptographically random bytes
13
+ * bodyHashHex = lowercase hex of SHA-256(exact submitted body bytes)
14
+ * macInput = UTF8(JSON.stringify([
15
+ * "wtt-request-signature", 1, replayNamespace, publicOrigin,
16
+ * keyId, timestampMs, nonceHex, method, requestTarget, contentType, bodyHashHex
17
+ * ]))
18
+ * mac = base64url(HMAC-SHA-256(key, macInput))
19
+ * transactionId = base64url(UTF8(JSON.stringify([1, keyId, timestampMs, nonceHex, mac])))
20
+ * ```
21
+ *
22
+ * All encodings are UTF-8. Base64url is unpadded and canonical
23
+ * (decode/re-encode must match). JSON arrays carry no whitespace.
24
+ */
25
+ /** Frozen wire protocol version. Unknown versions are rejected. */
26
+ declare const PROTOCOL_VERSION: 1;
27
+ /** Domain-separation label that opens every MAC input tuple. */
28
+ declare const MAC_INPUT_LABEL: "wtt-request-signature";
29
+ /** Default signature header field name. */
30
+ declare const DEFAULT_ATTESTATION_HEADER: "x-client-transaction-id";
31
+ /** Package response marker used only for the pre-handler stale-key signal. */
32
+ declare const ATTESTATION_ERROR_RESPONSE_HEADER: "x-attestation-error";
33
+ /** Prefix for opaque replay keys derived per protection space + nonce. */
34
+ declare const REPLAY_KEY_PREFIX: "att:v1:";
35
+ /** Exact HMAC key length in bytes. */
36
+ declare const KEY_BYTES_LENGTH: 32;
37
+ /** Random nonce length in bytes (serialized as 32 lowercase hex chars). */
38
+ declare const NONCE_BYTES_LENGTH: 16;
39
+ /** Raw HMAC-SHA-256 output length in bytes (43 unpadded base64url chars). */
40
+ declare const MAC_BYTES_LENGTH: 32;
41
+ /** Key identifiers match `[A-Za-z0-9_-]{1,64}` (task section 4.1). */
42
+ declare const MAX_KEY_ID_LENGTH: 64;
43
+ /** Replay namespaces match `[A-Za-z0-9_-]{1,128}` (task section 4.2). */
44
+ declare const MAX_REPLAY_NAMESPACE_LENGTH: 128;
45
+ /** Encoded transaction-ID header value: at most 1024 UTF-8 bytes. */
46
+ declare const MAX_ENCODED_TRANSACTION_ID_BYTES: 1024;
47
+ /** Canonical request target: at most 8192 UTF-8 bytes. */
48
+ declare const MAX_REQUEST_TARGET_BYTES: 8192;
49
+ /** Normalized content type: at most 256 UTF-8 bytes. */
50
+ declare const MAX_CONTENT_TYPE_BYTES: 256;
51
+ /** Canonical public origin: at most 512 UTF-8 bytes. */
52
+ declare const MAX_PUBLIC_ORIGIN_BYTES: 512;
53
+ /** Opaque replay keys: 1-256 printable ASCII bytes. */
54
+ declare const MIN_REPLAY_KEY_BYTES: 1;
55
+ declare const MAX_REPLAY_KEY_BYTES: 256;
56
+ /** Configured signature header names: valid HTTP tokens, at most 128 bytes. */
57
+ declare const MAX_HEADER_NAME_BYTES: 128;
58
+ /** Default parser/body byte budget (1 MiB); hard v1 cap is 16 MiB. */
59
+ declare const DEFAULT_MAX_BODY_BYTES: 1048576;
60
+ declare const MAX_BODY_BYTES_HARD_CAP: 16777216;
61
+ /** Time policy defaults and bounds in milliseconds (task section 4.4). */
62
+ declare const DEFAULT_MAX_AGE_MS: 30000;
63
+ declare const MIN_MAX_AGE_MS: 1000;
64
+ declare const MAX_MAX_AGE_MS: 120000;
65
+ declare const DEFAULT_CLOCK_SKEW_MS: 5000;
66
+ declare const MIN_CLOCK_SKEW_MS: 0;
67
+ declare const MAX_CLOCK_SKEW_MS: 30000;
68
+ declare const DEFAULT_CLUSTER_CLOCK_GUARD_MS: 5000;
69
+ declare const MIN_CLUSTER_CLOCK_GUARD_MS: 0;
70
+ declare const MAX_CLUSTER_CLOCK_GUARD_MS: 30000;
71
+ /** Global retention ceiling, including the store-clock guard allowance. */
72
+ declare const GLOBAL_MAX_RETENTION_MS: 240000;
73
+ /** SHA-256 of zero bytes: the digest of an actually empty request body. */
74
+ declare const EMPTY_BODY_SHA256_HEX: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
75
+ /**
76
+ * Error thrown by shared protocol helpers for malformed input, failed
77
+ * canonical checks, bound violations, and invalid option values.
78
+ *
79
+ * Server middleware (ATT-02) maps these to the fixed section 4.5 HTTP errors
80
+ * without echoing key material, nonces, signatures, or request bodies.
81
+ */
82
+ declare class AttestationProtocolError extends Error {
83
+ readonly name = "AttestationProtocolError";
84
+ constructor(message: string);
85
+ }
86
+ /**
87
+ * Decoded transaction-ID envelope: `[1, keyId, timestampMs, nonceHex, mac]`.
88
+ * Exactly 5 elements; extra array fields are rejected.
89
+ */
90
+ type AttestationEnvelope = readonly [
91
+ version: 1,
92
+ keyId: string,
93
+ timestampMs: number,
94
+ nonceHex: string,
95
+ mac: string
96
+ ];
97
+ /**
98
+ * MAC input tuple. EVERY protected field participates; altering method,
99
+ * target, query spelling/order, content type, body bytes, namespace, origin,
100
+ * version, key ID, timestamp, or nonce invalidates the MAC.
101
+ */
102
+ type AttestationMacInput = readonly [
103
+ label: 'wtt-request-signature',
104
+ version: 1,
105
+ replayNamespace: string,
106
+ publicOrigin: string,
107
+ keyId: string,
108
+ timestampMs: number,
109
+ nonceHex: string,
110
+ method: string,
111
+ requestTarget: string,
112
+ contentType: string,
113
+ bodyHashHex: string
114
+ ];
115
+ /** Fields needed to build the MAC input tuple. */
116
+ interface MacInputFields {
117
+ readonly replayNamespace: string;
118
+ readonly publicOrigin: string;
119
+ readonly keyId: string;
120
+ readonly timestampMs: number;
121
+ readonly nonceHex: string;
122
+ /** Actual method, normalized to uppercase. */
123
+ readonly method: string;
124
+ /** Canonical origin-form request target (encoded bytes preserved). */
125
+ readonly requestTarget: string;
126
+ /** Single final content type with outer HTTP whitespace trimmed. */
127
+ readonly contentType: string;
128
+ /** Lowercase hex SHA-256 of the exact submitted body bytes. */
129
+ readonly bodyHashHex: string;
130
+ }
131
+ /** Fields carried by the encoded transaction-ID envelope. */
132
+ interface TransactionIdFields {
133
+ readonly keyId: string;
134
+ readonly timestampMs: number;
135
+ readonly nonceHex: string;
136
+ /** 43 canonical unpadded base64url chars (32 raw bytes). */
137
+ readonly mac: string;
138
+ }
139
+ /**
140
+ * Low-level signer input. Callers supply explicit timestamp/nonce/body digest
141
+ * so identical inputs are deterministic; only the request wrapper (ATT-07)
142
+ * chooses fresh timestamps/nonces automatically.
143
+ */
144
+ interface SignInput {
145
+ readonly method: string;
146
+ readonly requestTarget: string;
147
+ readonly contentType: string;
148
+ readonly bodyHashHex: string;
149
+ readonly timestampMs: number;
150
+ readonly nonceHex: string;
151
+ }
152
+ /**
153
+ * Low-level HMAC signer (implemented with WebCrypto in ATT-06). A returned
154
+ * signer exposes immutable protection-space binding plus `sign(input)`.
155
+ */
156
+ interface RequestSigner {
157
+ readonly version: 1;
158
+ readonly keyId: string;
159
+ readonly publicOrigin: string;
160
+ readonly replayNamespace: string;
161
+ sign(input: SignInput): Promise<string>;
162
+ }
163
+ /** Options accepted by `createRequestSigner` (ATT-06). */
164
+ interface RequestSignerOptions {
165
+ readonly keyId: string;
166
+ /** Exactly 32 bytes; copied on input, never exposed. */
167
+ readonly key: Uint8Array;
168
+ readonly publicOrigin: string;
169
+ readonly replayNamespace: string;
170
+ }
171
+ /** Partial time-policy overrides in milliseconds. */
172
+ interface TimePolicyOptions {
173
+ readonly maxAgeMs?: number;
174
+ readonly clockSkewMs?: number;
175
+ readonly clusterClockGuardMs?: number;
176
+ }
177
+ /** Validated, frozen time policy. All values are safe-integer milliseconds. */
178
+ interface ResolvedTimePolicy {
179
+ readonly maxAgeMs: number;
180
+ readonly clockSkewMs: number;
181
+ readonly clusterClockGuardMs: number;
182
+ }
183
+ /** Proof-timing verdicts. Key lookup happens first: unknown/inactive keys are `stale-key`. */
184
+ type ProofTimingDecision = 'valid' | 'future' | 'expired' | 'stale-key';
185
+ /**
186
+ * Result of evaluating one proof's timing. Deadline fields are `null` only
187
+ * when the timestamp is so large that deadline arithmetic leaves safe-integer
188
+ * range; that proof is rejected as `expired`.
189
+ */
190
+ interface ProofTiming {
191
+ readonly decision: ProofTimingDecision;
192
+ /** `timestampMs + maxAgeMs + clockSkewMs`. */
193
+ readonly timeDeadlineMs: number | null;
194
+ /** `min(timeDeadlineMs, acceptUntil)`. */
195
+ readonly proofDeadlineMs: number | null;
196
+ /** `proofDeadlineMs + clusterClockGuardMs`: full replay retention deadline. */
197
+ readonly retainUntilMs: number | null;
198
+ }
199
+ /** Retention admission verdicts for replay stores. */
200
+ type RetentionAdmission = 'admissible' | 'expired' | 'overlong';
201
+
202
+ /** Key IDs match `[A-Za-z0-9_-]{1,64}` (task section 4.1 snapshot rules). */
203
+ declare function validateKeyId(keyId: unknown): asserts keyId is string;
204
+ /** Replay namespaces match `[A-Za-z0-9_-]{1,128}`. */
205
+ declare function validateReplayNamespace(namespace: unknown): asserts namespace is string;
206
+ /** Nonces are exactly 32 lowercase hex chars (16 random bytes). */
207
+ declare function validateNonceHex(nonceHex: unknown): asserts nonceHex is string;
208
+ /** Body digests are lowercase hex SHA-256 (32 bytes). */
209
+ declare function validateBodyHashHex(bodyHashHex: unknown): asserts bodyHashHex is string;
210
+ /** MACs are exactly 43 canonical base64url chars decoding to 32 bytes. */
211
+ declare function validateMac(mac: unknown): asserts mac is string;
212
+ /** HMAC key material is exactly 32 bytes. */
213
+ declare function validateKeyBytes(key: unknown): asserts key is Uint8Array;
214
+ /**
215
+ * Canonical HTTP(S) origin validation. The value must already be canonical:
216
+ * lowercase scheme/host, no userinfo, no path/query/fragment, no trailing
217
+ * slash, no redundant default port. HTTPS is required except for loopback
218
+ * development (`localhost`, `127.0.0.0/8`, `[::1]`). Never derive this from
219
+ * `Host`, `Forwarded`, or `X-Forwarded-*` — it is static configuration.
220
+ */
221
+ declare function validatePublicOrigin(origin: unknown): asserts origin is string;
222
+ /**
223
+ * Actual method normalized to uppercase. The method is a valid HTTP token;
224
+ * lowercase input such as `get` normalizes to `GET` on both sides.
225
+ */
226
+ declare function normalizeMethod(method: unknown): string;
227
+ /**
228
+ * Single final `Content-Type` value with outer HTTP whitespace (SP/HTAB)
229
+ * trimmed; remaining bytes — including a generated multipart boundary — are
230
+ * preserved. Missing values normalize to `""`. Controls are rejected and
231
+ * the normalized value is bounded to 256 UTF-8 bytes.
232
+ */
233
+ declare function normalizeContentType(contentType: unknown): string;
234
+ /**
235
+ * Canonical origin-form request target.
236
+ *
237
+ * The signer uses the final `Request.url` pathname plus raw search string;
238
+ * Express uses `req.originalUrl` plus a configured stripped-proxy prefix.
239
+ * Either way the ENCODED bytes are preserved: reserved path escapes, case and
240
+ * spelling of percent escapes, trailing/doubled slashes, duplicate query
241
+ * parameters, query order, and `+` all survive untouched — no `req.query`
242
+ * sorting, no JSON query reserialization, no decoding.
243
+ *
244
+ * The single normalization both sides apply: a lone empty `?` is treated as
245
+ * no search string (`/items?` signs as `/items`).
246
+ *
247
+ * Rejected before any allocation: non-origin forms, `//authority` targets,
248
+ * fragments, backslashes, controls, raw spaces, malformed percent escapes,
249
+ * and canonical/noncanonical dot-segment forms (including `%2e` spellings).
250
+ */
251
+ declare function canonicalizeRequestTarget(target: unknown): string;
252
+ /**
253
+ * Configured signature header-name validation. Names must be valid HTTP
254
+ * tokens and must not collide with auth, cookie, host/origin, body-metadata,
255
+ * or the package's `x-attestation-error` response marker.
256
+ */
257
+ declare function validateHeaderName(name: unknown): asserts name is string;
258
+ /**
259
+ * Exactly-one incoming signature field. Returns `null` when absent (the
260
+ * server maps that to `ATTESTATION_MISSING`); throws on duplicate raw-header
261
+ * presentations or comma-joined values instead of selecting one value.
262
+ */
263
+ declare function extractSingleHeaderValue(raw: string | readonly string[] | undefined): string | null;
264
+ /** Opaque replay keys are 1-256 printable ASCII bytes; never truncated. */
265
+ declare function validateReplayKey(replayKey: unknown): asserts replayKey is string;
266
+ /**
267
+ * Exact MAC input bytes: `UTF8(JSON(["wtt-request-signature",1,...])))`.
268
+ * Method, target, and content type are normalized exactly as the verifier
269
+ * normalizes them, so both sides frame identical bytes.
270
+ */
271
+ declare function buildMacInputBytes(fields: MacInputFields): Uint8Array;
272
+ /**
273
+ * Canonical replay-key preimage bytes: `UTF8(JSON([namespace, origin,
274
+ * nonceHex]))`. The caller hashes these with SHA-256 and prefixes the hex
275
+ * with `att:v1:` — the nonce stays single-use across accepted rotations in
276
+ * one protection space, while path, token, session, key ID, and instance
277
+ * stay OUT of the key.
278
+ */
279
+ declare function buildReplayKeyPreimageBytes(input: {
280
+ readonly replayNamespace: string;
281
+ readonly publicOrigin: string;
282
+ readonly nonceHex: string;
283
+ }): Uint8Array;
284
+ /**
285
+ * Encode the transaction-ID envelope `[1,keyId,timestampMs,nonceHex,mac]`
286
+ * as unpadded base64url UTF-8 JSON. The 1024-byte bound is enforced on the
287
+ * encoded output.
288
+ */
289
+ declare function encodeTransactionId(fields: TransactionIdFields): string;
290
+ /**
291
+ * Strict envelope decode. The 1024-byte bound is enforced BEFORE decoding;
292
+ * then canonical base64url, UTF-8, canonical JSON array shape, version 1,
293
+ * and every field rule are enforced. Unknown versions, extra array fields,
294
+ * alternate numeric spellings, padding, and duplicate protocol material all
295
+ * throw.
296
+ */
297
+ declare function decodeTransactionId(headerValue: unknown): TransactionIdFields;
298
+
299
+ /** UTF-8 encode without BOM. */
300
+ declare function utf8Encode(text: string): Uint8Array;
301
+ /** UTF-8 decode; rejects lone surrogates and invalid sequences. */
302
+ declare function utf8Decode(bytes: Uint8Array): string;
303
+ /** UTF-8 byte length of a string. */
304
+ declare function utf8ByteLength(text: string): number;
305
+ /**
306
+ * Unpadded canonical base64url encode.
307
+ *
308
+ * @param bytes raw bytes to encode.
309
+ */
310
+ declare function base64urlEncode(bytes: Uint8Array): string;
311
+ /**
312
+ * Strict canonical base64url decode.
313
+ *
314
+ * Rejects padding (`=`), foreign alphabets, impossible lengths
315
+ * (`length % 4 === 1`), and non-canonical trailing bits (decode/re-encode
316
+ * must reproduce the input exactly).
317
+ */
318
+ declare function base64urlDecode(input: string): Uint8Array;
319
+ /**
320
+ * Whitespace-free canonical JSON serialization of a JSON value, as UTF-8
321
+ * bytes. `JSON.stringify` output is already whitespace-free; anything it
322
+ * cannot represent (`undefined`, functions, circular graphs) is rejected.
323
+ */
324
+ declare function canonicalJsonBytes(value: unknown): Uint8Array;
325
+ /**
326
+ * Strict canonical JSON array parse.
327
+ *
328
+ * The raw text must round-trip `JSON.parse`/`JSON.stringify` byte-for-byte,
329
+ * which rejects whitespace, alternate numeric spellings (`1.0`, `1e3`,
330
+ * `+1`, `-0`), non-minimal string escapes, and duplicate-key-tolerant
331
+ * reserialization. The parsed value must be an array of exactly
332
+ * `expectedLength` elements when given.
333
+ */
334
+ declare function parseCanonicalJsonArray(text: string, expectedLength?: number): unknown[];
335
+ /** Assert a value is a non-negative safe-integer epoch-millisecond count. */
336
+ declare function assertTimestampMs(name: string, value: unknown): asserts value is number;
337
+
338
+ /**
339
+ * Validate partial overrides and return the frozen effective policy.
340
+ * Unknown option keys are rejected so typos cannot silently widen windows.
341
+ */
342
+ declare function resolveTimePolicy(options?: TimePolicyOptions): ResolvedTimePolicy;
343
+ /**
344
+ * Evaluate one proof's timing against a KNOWN key entry's acceptance window.
345
+ *
346
+ * Key-status ordering follows section 4.5: unknown/inactive well-formed key
347
+ * IDs are `stale-key` (the caller determines key lookup; pass the entry's
348
+ * window here), a known active key with an elapsed timestamp is `expired`,
349
+ * and excessive future skew is `future`. The verifier repeats this check
350
+ * after async provider work, immediately before reservation, and before
351
+ * calling the handler after reservation (ATT-02).
352
+ *
353
+ * Deadline arithmetic must remain safe integers; a timestamp so large that
354
+ * `timestampMs + maxAgeMs + clockSkewMs` leaves safe-integer range cannot
355
+ * prove freshness and is rejected as `expired` with `null` deadlines.
356
+ */
357
+ declare function evaluateProofTiming(input: {
358
+ readonly timestampMs: number;
359
+ readonly nowMs: number;
360
+ readonly acceptFrom: number;
361
+ readonly acceptUntil: number;
362
+ readonly policy: ResolvedTimePolicy;
363
+ }): ProofTiming;
364
+ /**
365
+ * Maximum remaining retention measured AT a verifier:
366
+ * `maxAgeMs + 2 * clockSkewMs + clusterClockGuardMs` (default 45000 ms).
367
+ */
368
+ declare function maxVerifierRemainingMs(policy: ResolvedTimePolicy): number;
369
+ /**
370
+ * Store admission bound: `maxAgeMs + 2 * clockSkewMs + 2 * clockGuardMs`
371
+ * (default 50000 ms). A store clock may additionally trail the verifier by
372
+ * the declared guard. Capped globally at 240000 ms.
373
+ */
374
+ declare function maxStoreAdmissionMs(policy: ResolvedTimePolicy): number;
375
+ /**
376
+ * Check whether a store may admit `retainUntilMs` at `nowMs`. Stores reject
377
+ * unsafe, already-expired, or overlong retention INSTEAD of truncating it.
378
+ */
379
+ declare function checkRetentionAdmissible(input: {
380
+ readonly retainUntilMs: number;
381
+ readonly nowMs: number;
382
+ readonly policy: ResolvedTimePolicy;
383
+ }): RetentionAdmission;
384
+
385
+ export { evaluateProofTiming as $, ATTESTATION_ERROR_RESPONSE_HEADER as A, type ProofTimingDecision as B, REPLAY_KEY_PREFIX as C, DEFAULT_ATTESTATION_HEADER as D, EMPTY_BODY_SHA256_HEX as E, type RequestSigner as F, GLOBAL_MAX_RETENTION_MS as G, type RequestSignerOptions as H, type RetentionAdmission as I, type TransactionIdFields as J, KEY_BYTES_LENGTH as K, assertTimestampMs as L, MAC_BYTES_LENGTH as M, NONCE_BYTES_LENGTH as N, base64urlDecode as O, type ProofTiming as P, base64urlEncode as Q, type ResolvedTimePolicy as R, type SignInput as S, type TimePolicyOptions as T, buildMacInputBytes as U, buildReplayKeyPreimageBytes as V, canonicalJsonBytes as W, canonicalizeRequestTarget as X, checkRetentionAdmissible as Y, decodeTransactionId as Z, encodeTransactionId as _, type AttestationEnvelope as a, extractSingleHeaderValue as a0, maxStoreAdmissionMs as a1, maxVerifierRemainingMs as a2, normalizeContentType as a3, normalizeMethod as a4, parseCanonicalJsonArray as a5, resolveTimePolicy as a6, utf8ByteLength as a7, utf8Decode as a8, utf8Encode as a9, validateBodyHashHex as aa, validateHeaderName as ab, validateKeyBytes as ac, validateKeyId as ad, validateMac as ae, validateNonceHex as af, validatePublicOrigin as ag, validateReplayKey as ah, validateReplayNamespace as ai, type AttestationMacInput as b, AttestationProtocolError as c, DEFAULT_CLOCK_SKEW_MS as d, DEFAULT_CLUSTER_CLOCK_GUARD_MS as e, DEFAULT_MAX_AGE_MS as f, DEFAULT_MAX_BODY_BYTES as g, MAC_INPUT_LABEL as h, MAX_BODY_BYTES_HARD_CAP as i, MAX_CLOCK_SKEW_MS as j, MAX_CLUSTER_CLOCK_GUARD_MS as k, MAX_CONTENT_TYPE_BYTES as l, MAX_ENCODED_TRANSACTION_ID_BYTES as m, MAX_HEADER_NAME_BYTES as n, MAX_KEY_ID_LENGTH as o, MAX_MAX_AGE_MS as p, MAX_PUBLIC_ORIGIN_BYTES as q, MAX_REPLAY_KEY_BYTES as r, MAX_REPLAY_NAMESPACE_LENGTH as s, MAX_REQUEST_TARGET_BYTES as t, MIN_CLOCK_SKEW_MS as u, MIN_CLUSTER_CLOCK_GUARD_MS as v, MIN_MAX_AGE_MS as w, MIN_REPLAY_KEY_BYTES as x, type MacInputFields as y, PROTOCOL_VERSION as z };