@request-seal/core 0.2.0 → 0.4.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 (46) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/README.md +10 -9
  3. package/dist/adapters/node-http/index.d.ts +8 -1
  4. package/dist/adapters/node-http/index.js +21 -5
  5. package/dist/constants.d.ts +4 -4
  6. package/dist/constants.js +2 -2
  7. package/dist/crypto/algorithm.d.ts +4 -0
  8. package/dist/crypto/algorithm.js +12 -0
  9. package/dist/discovery/body.d.ts +12 -2
  10. package/dist/discovery/body.js +33 -11
  11. package/dist/discovery/cache.d.ts +13 -3
  12. package/dist/discovery/cache.js +103 -39
  13. package/dist/discovery/headers.d.ts +2 -0
  14. package/dist/discovery/headers.js +27 -1
  15. package/dist/discovery/index.d.ts +9 -2
  16. package/dist/discovery/index.js +22 -6
  17. package/dist/discovery/node.d.ts +6 -2
  18. package/dist/discovery/node.js +124 -18
  19. package/dist/discovery/proof.js +2 -2
  20. package/dist/discovery/snapshot.js +3 -0
  21. package/dist/errors/parity.d.ts +29 -3
  22. package/dist/errors/parity.js +32 -3
  23. package/dist/errors/types.d.ts +4 -2
  24. package/dist/errors/types.js +14 -2
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/jose/nested.js +23 -1
  28. package/dist/key_handle.js +23 -1
  29. package/dist/message/index.js +2 -1
  30. package/dist/policy.d.ts +13 -1
  31. package/dist/policy.js +13 -1
  32. package/dist/quorum/evaluation.d.ts +1 -1
  33. package/dist/quorum/evaluation.js +18 -4
  34. package/dist/replay/postgres.d.ts +2 -0
  35. package/dist/replay/postgres.js +6 -0
  36. package/dist/sign.d.ts +22 -1
  37. package/dist/sign.js +28 -6
  38. package/dist/signature-fields.d.ts +5 -1
  39. package/dist/signature-fields.js +9 -1
  40. package/dist/telemetry.d.ts +43 -0
  41. package/dist/telemetry.js +86 -0
  42. package/dist/verify-quorum.js +34 -9
  43. package/dist/verify.d.ts +19 -1
  44. package/dist/verify.js +48 -6
  45. package/dist/web-bot-auth/verify.js +7 -3
  46. package/package.json +13 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0 - October 17, 2026
6
+ - Upgrade notes for callers: the new telemetry surface is entirely opt-in — no code changes unless you attach a subscriber; `Telemetry.attach` accepts at most sixteen subscribers and every event is a frozen six-field object whose `reason` values are the same parity rule ids the errors carry.
7
+ - Add the versioned telemetry contract v1, mirroring RequestSeal for Elixir's `:telemetry` surface with zero dependencies: `Telemetry.attach(subscriber)` returns a caller-owned handle whose `detach` stops delivery, and five events carry bounded, low-cardinality payloads — `verify`, `verifyQuorum`, and `webBotAuthVerify` report `ok` or `error` with the parity rule id, layer, and bounded detail exactly as the caller receives them; `replayClaim` reports `claimed`, `already_claimed`, or `error`; `custodySign` reports `ok` or `error` with the custody reason. `durationMs` is monotonic milliseconds over the emitting call. No signature label, key ID, URL, principal, nonce, bytes, or exception message ever enters an event; internal faults (`core/internal.internal`) are countable through the closed reason vocabulary. A throwing subscriber degrades silently and never fails the operation it observes; attachment is bounded to sixteen concurrent subscribers. The event names, outcomes, and fields are additive-only within minor versions.
8
+
9
+
10
+ Upgrade notes for callers: the discovery `Resolution.assertedAlgorithm` is now the normalized JOSE name (`JwsAlgorithm | null`) and an asserted key's `Resolution.algorithm` is the `{jws}` selector, so code matching the raw HTTP token string must compare JOSE names; a cache `fetch` callback may now receive a `validators` option and return a `NotModified` outcome (a callback that ignores both still works); `CoreError.retryable` is `boolean` and `discovery/resolution_failed` is retryable; a first `103` no longer fails a discovery fetch.
11
+ - Re-run the reciprocal exchange against the published RequestSeal for Elixir 0.5.2: both interop peers pin Hex `request_seal == 0.5.2` with the release checksum, the CI reference clone tracks the v0.5.2 tag, and the executed exchange agrees with zero disagreements in every family and both directions (receipt under the project's check records).
12
+ - Move the seven architecture decision records from `docs/adr/` into the maintainer's private record store and state each accepted decision in place in the threat model, mirroring the Elixir repository's relocation; public documents no longer reference the records and no library behavior changed.
13
+ - Add `ddlIndex(table)` to `@request-seal/core/replay-postgres`, the retention-sweep index DDL mirroring the reference `ddl_index/1`: without it the periodic `DELETE ... WHERE retain_until <= now` scans the whole growing table; the table name is additionally bounded to 46 characters so the derived index identifier fits PostgreSQL's 63-byte limit.
14
+ - Make `verify`, `verifyQuorum`, Web Bot Auth `verify`, and `sign` total at their entry points: an unexpected internal fault returns the new bounded `core/internal.internal` with the exception class (`TypeError`, `Error`, `throw`) as `detail` instead of escaping or masking as `invalid_options`/`invalid_policy`/`invalid_quorum`. Caller callbacks keep their deliberate classifications (a throwing key resolver stays `key_resolver_failed`, a failing signer `signer_failed`, a throwing extension pipeline step `invalid_policy`), and a defective label inside `verifyQuorum` becomes that label's nonqualifying internal fact rather than failing the whole call.
15
+ - Bound the quorum assignment search by the caller's wall clock: a `Context` deadline that expires mid-search now preserves a satisfying best-so-far assignment and, when nothing satisfies, returns the new retryable `core/quorum.deadline` (the first retryable core reason; `CoreError.retryable` widens to `boolean`) instead of a custody error. Node exhaustion and deadline exhaustion share one rejection gate.
16
+ - Split DNS resolution failures (RFC-level taxonomy from the reference): an authoritative NXDOMAIN on every query returns the new nonretryable `discovery/domain_not_found`, while any other resolution trouble returns `discovery/resolution_failed`, which is now retryable as the reference marks it.
17
+ - Skip interim 1xx responses (RFC 9110 Section 15.2): up to four informational responses preceding the final status are skipped instead of rejecting the fetch with `unexpected_status`; a fifth interim or a 101 protocol switch still rejects.
18
+ - Check a CIMD document's nested `jwks_uri` against the origin that served the document rather than the configured origin, so a permissive `redirectScope: "any_https"` with a cross-origin redirect to a mirror resolves the mirror's key set instead of failing `redirect_denied`.
19
+ - Revalidate conditionally (RFC 9111 Section 4.3.4): a fetched key set carries the publisher's `ETag` (at most 512 bytes), `Discovery.fetchWithValidators(source, etag, storedLifetime, options)` sends `If-None-Match` and returns a `NotModified` outcome on 304 — the stored lifetime persists when the 304 carries no freshness directives, a 304 naming a different validator rejects `invalid_response`, and a validator never crosses a redirect. A cache wired with the conditional fetcher sends its stored validator when every stored key is still alive (a dead key forces a full fetch) and never for a CIMD source; `DiscoveryFetch` gains the `validators` option and the `NotModified` result — a breaking type change for 0.3.0.
20
+ - Narrow the nested-JOSE inner `cty` rule to JOSE media types (RFC 7515 Sections 4.1.10 and 9.2.1): an inner JWS `cty` of `application/jose`, `application/jose+json`, `application/jws`, `application/jwe`, or `application/jwt` — case-insensitively, with parameters stripped before the abbreviation rule, so `JWT`, `jose`, and `JWT;x="a/b"` all classify — rejects `nesting_depth`, while any other inner `cty` such as `application/json` names ordinary typed payload content and verifies.
21
+ - Re-pin the shared error vocabulary to RequestSeal for Elixir v0.5.2 (release commit `53b6690`): the extractor now reads 0.5.2's option-style retryable spellings, and the three new reasons arrive with the pin.
22
+ - Accept both registry spellings of a JWK `alg` assertion and normalize once at import to the JOSE name, as RequestSeal for Elixir 0.5.2 already did. An RFC 7517 set that asserts `RS256` or `EdDSA` now resolves through either allowlist spelling — the HTTP identifier (`rsa-v1_5-sha256`) or the `{jws}` selector — and PS256 and PS384, which exist only as JOSE names, select by selector. A key whose `alg` names an algorithm this verifier never uses (an encryption key such as `RSA-OAEP`, or a name outside both registries) or a well-formed key of an unsupported `kty`/curve (RFC 7517 Section 5) is skipped while the rest of the set still resolves; an import failure classified `invalid_key` (an unknown `kty`, an unsupported curve, an oversized component) skips the same way, and an all-skipped set resolves empty with lookups failing closed. Leaked private members (`d`, `p`, `q`, `dp`, `dq`, `qi`, `oth`, `k`) still reject the whole set loudly before any skip decision, a malformed non-string `alg` still rejects, and `maxKeys` still counts every listed key. `Resolution.assertedAlgorithm` now carries the normalized JOSE name instead of the raw HTTP token, and an asserted key's `Resolution.algorithm` is the `{jws}` selector — breaking type changes for 0.3.0.
23
+ - Zero discovery snapshot freshness when `no-store` or `no-cache` arrives qualified, as in `no-cache="content-type"` (RFC 9111 Sections 5.2.2.3 and 5.2.2.4, including the RFC 7234 quoted form whose member can itself contain commas). The zero-freshness check compared whole comma-split elements, so a qualified directive did not zero the lifetime and its keys cached up to the 86,400 s default `maxTtl`. The directive name before any `=` now decides, as RequestSeal for Elixir 0.5.2 already did.
24
+ - Re-vendor the shared conformance corpus to RequestSeal for Elixir v0.5.2 and update the pin. The v0.5.2 corpus carries the same 294 byte-identical files as v0.4.0; the pin and index now record the new tag, release commit, and index digest.
25
+ - Test on Node 22.23.1 in CI, beside 22.12.0, 24.21.0, and 26.11.1 (Linux only).
26
+ - Match IP-literal discovery hosts against the certificate's IP addresses with OpenSSL (`X509Certificate.checkIP`). On Node 22.23.1, `tls.checkServerIdentity` normalizes the host with `domainToASCII` before its IP test, which turns `::1` into an empty string, so every `https://[IPv6]` source failed with `discovery/tls_failed`. DNS hosts still use `tls.checkServerIdentity`.
27
+ - Report a missing or empty OS trust store as `discovery/transport_unavailable` instead of custody `unsupported_format` when `@request-seal/core/discovery-node` creates or fetches a `cacerts: "os"` source. Node before 22.15.0, including 22.12.0, and Deno 2.5.6 have no `tls.getCACertificates`; the refusal stays (bundled roots are never a fallback) but now has a discovery kind, the same as a denied store. Callers matching the old custody reason must update.
28
+ - Add an optional `clock` to `Discovery.resolver`, so a key's freshness is checked on the same clock as the policy. With a cache, the clock decides only the returned key's freshness; the cache keeps fetching, storing, and expiring its entries on its own clock. A clock that throws or returns an invalid time gives `invalid_options`. Without it the key set bridge keeps system time, which disagreed with a policy running on a fixed or offset clock.
29
+ - Give each request captured by `capture(server)` its own deadline: the new `timeoutMs` option (1 to 300,000 ms, default 5,000) applies per request, and `context` is now refused with `adapter/invalid_options`. A Context passed to `capture(server)` was one absolute deadline shared by every later request, so once it passed, every capture on that server failed with `custody/deadline_exceeded`.
30
+ - Bound exact-input function signers by `signingTimeout` when no `context` is passed, as generated specs already did; the option was accepted and ignored, and such signers always ran under a fixed 5,000 ms deadline. A function that outlasts its `signingTimeout` now returns `signer_failed`.
31
+ - Generate `Content-Digest` in `Message.request` and `Message.response` with the `crypto.subtle.digest` captured at load, as every other cryptographic operation already did; code that replaces the global after RequestSeal loads can no longer change a generated digest.
32
+ - Declare a WebKit 27.2 limitation on Linux x64: the engine rejects PKCS8 import of an Ed25519 private key whose seed begins with `0x00`, so `Custody.local` returns `custody/invalid_key` for that form; pass such keys as JWK. The browser suite executes it with a fixed seed and uses published RFC 8032 keys instead of generating Ed25519 keys, which this engine intermittently fails to do. Correct the runtime matrix: Node 22.12.0 and Deno 2.5.6 refuse `cacerts: "os"`, Fetch verification is refused on every runtime except Deno 2.5.6 (including Node 22.12.0 and Bun 1.3.14), and WebKit verifies empty-message Ed25519 signatures on Linux.
33
+ - Document supported versions and how to verify release provenance with `npm audit signatures` in SECURITY.md.
34
+ - Documentation pass, with no library behavior change: correct option lists, defaults, limits, and error reasons across the guides and references against the source; add a key rotation guide and a glossary; replace the extension-profile sketch with an executed program and add an executed AES key-wrapping JOSE example; describe parameters, results, and errors in the API reference for `sign`, `verify`, and `Policy.create`; vary example domains; add npm keywords.
35
+
36
+ ### Migrating from 0.2.x
37
+ - `capture(server, { context })` now returns `adapter/invalid_options`. Replace `context` with `timeoutMs` (1 to 300,000 ms, default 5,000), which gives each request its own deadline. Pass a `context` per call to `captureRequest` instead if you need one.
38
+ - A function signer used with an exact-input spec (`signatureInput`) and no `context` now runs under `signingTimeout` (default 5,000 ms). A signer slower than its `signingTimeout` now fails with `signer_failed`; raise `signingTimeout` or pass a `context` with the deadline you want.
39
+ - `@request-seal/core/discovery-node` now reports a missing or empty OS trust store as `discovery/transport_unavailable`. Code that matched custody `unsupported_format` for `cacerts: "os"` should match `discovery/transport_unavailable`, or supply DER roots in `cacerts`.
40
+
5
41
  ## 0.2.0 - October 11, 2026
6
42
  - Rewrite the README and add eleven usage guides, architecture decisions, and threat-model documentation. Every complete TypeScript example runs in CI; README verification excerpts are checked against the programs they come from. Generate the API reference with TypeDoc and publish it to GitHub Pages at https://baselabs.github.io/request-seal-typescript/. The migration guide compares behavior with `http-message-signatures` 1.0.6 using executed examples.
7
43
  - Verify interoperability with RequestSeal for Elixir 0.4.1 for every HTTP and JWS algorithm, all JWE alg/enc pairs, nested JWS-in-JWE, signed TLS Web Bot Auth directories, and shared PostgreSQL replay claims.
@@ -21,7 +57,7 @@
21
57
  - Rename the npm package from `request-seal` to `@request-seal/core` under the `request-seal` organization; use `@request-seal/core/<subpath>` for every subpath import.
22
58
  - Add captured Node HTTP Web Bot Auth verification, shared attempt storage, and executable signed TLS directory examples.
23
59
  - Reject concurrent capture consumers and retained length mismatches; derive HTTP/2 scheme from TLS and reject conflicting Host/authority.
24
- - Add captured-request quorum verification with shared replay and single-verification guards.
60
+ - Add captured-request quorum verification with single-verification guards. Quorum verification refuses a required replay policy with `invalid_policy`; one replay claim belongs only after the complete quorum verifies.
25
61
  - Apply redirect method/body rules, validate redirect policy before delivery, remove Location fragments, and report actual client attempts.
26
62
  - Measure Fetch runtime overrides and eligibility in the wire lane; refuse Node sec-fetch-mode signing, empty-query targets on Node majors below 24, and planted runtime markers.
27
63
  - Deliver response signatures with their exact buffered bytes, use one clock for Date and created, and omit prohibited lengths.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- **Status:** maintained · **Kind:** overview · **Updated:** October 11, 2026 · **Governed by:** RFC 9421, RFC 9530, the Web Bot Auth protocol draft, and the executed examples in this file · **Review when:** package scope, installation, or the public API changes
1
+ **Status:** maintained · **Kind:** overview · **Updated:** October 14, 2026 · **Governed by:** RFC 9421, RFC 9530, the Web Bot Auth protocol draft, and the executed examples in this file · **Review when:** package scope, installation, or the public API changes
2
2
 
3
3
  # RequestSeal
4
4
 
@@ -27,7 +27,7 @@ const key = await Custody.local("hmac-sha256", { hmac: crypto.getRandomValues(ne
27
27
  const server = createServer((req, res) => res.end(String(req.headers["signature-input"]))).listen(0, "127.0.0.1");
28
28
  await new Promise((ready) => server.once("listening", ready));
29
29
  const spec = { label: "sig", components: '("@method" "@authority" "@path")', algorithm: "hmac-sha256", expiresIn: 60 };
30
- const sent = await signedFetch(`http://127.0.0.1:${(server.address() as { port: number }).port}/orders`, { method: "GET" }, { spec, signer: key.ok ? key.value : assert.fail() });
30
+ const sent = await signedFetch(`http://127.0.0.1:${(server.address() as { port: number }).port}/reports`, { method: "GET" }, { spec, signer: key.ok ? key.value : assert.fail() });
31
31
  assert.ok(sent.ok && (await sent.value.response.text()).startsWith('sig=("@method" "@authority" "@path")')); server.close();
32
32
  ```
33
33
 
@@ -44,7 +44,7 @@ const must = <T>(r: Result<T>): T => { assert.ok(r.ok); return (r as { value: T
44
44
  const key = must(await Custody.local("hmac-sha256", { hmac: crypto.getRandomValues(new Uint8Array(32)) }));
45
45
  const components = '("@method" "@authority" "@path")';
46
46
  const policy = must(Policy.create({ algorithms: ["hmac-sha256"], components, keyResolver: () => ok({ algorithm: "hmac-sha256", key }), freshness: { clock: () => Math.floor(Date.now() / 1000), maxAge: 60, skew: 5, requireExpires: true }, content: "not_required", replay: "not_required" }));
47
- const signed = must(await sign(must(await Message.request("GET", "https://api.example/orders", [], "")), { label: "sig", components, algorithm: "hmac-sha256", expiresIn: 60 }, key));
47
+ const signed = must(await sign(must(await Message.request("GET", "https://api.example/documents", [], "")), { label: "sig", components, algorithm: "hmac-sha256", expiresIn: 60 }, key));
48
48
  must(await verify(signed, policy, { label: "sig" }));
49
49
  const altered = must(Message.create({ ...signed, method: "DELETE" }));
50
50
  const rejected = await verify(altered, policy, { label: "sig" });
@@ -231,7 +231,7 @@ await server.shutdown();
231
231
 
232
232
  ## Policy recipes
233
233
 
234
- A verifier states six choices: algorithms, required components, a key resolver, freshness, content checking, and replay. Nothing is defaulted. Copy the recipe closest to your case.
234
+ A verifier states six choices: algorithms, required components, a key resolver, freshness, content checking, and replay. None of the six has a default. Copy the recipe closest to your case.
235
235
 
236
236
  ### Strict RFC 9421
237
237
 
@@ -267,7 +267,7 @@ const policy = must(Policy.create({
267
267
 
268
268
  // A partner signs a request, and it verifies exactly once.
269
269
  const signer = must(await Custody.local("ed25519", pair.privateKey.export({ type: "pkcs8", format: "pem" }) as string));
270
- const request = must(await Message.request("POST", "https://api.example.com/orders?id=7", [], '{"qty":2}', { digest: ["sha-256"] }));
270
+ const request = must(await Message.request("POST", "https://api.example.com/tickets?id=7", [], '{"priority":2}', { digest: ["sha-256"] }));
271
271
  const signed = must(await sign(request, { label: "sig", components, algorithm: "ed25519", expiresIn: 60, keyid: "partner-1", digest: ["sha-256"] }, signer));
272
272
  must(await verify(signed, policy, { label: "sig" }));
273
273
  const again = await verify(signed, policy, { label: "sig" });
@@ -323,7 +323,7 @@ assert.ok(!again.ok && again.error.kind === "core" && again.error.reason === "re
323
323
 
324
324
  ## Reason to HTTP status (recommendation)
325
325
 
326
- Every failure is a `Result` with a bounded `error`; branch on `error.kind` and `error.reason`. This table is a recommendation, not a requirement. It is derived from the error vocabulary in [error parity](docs/reference/error-parity.md), and the [signing guide](docs/guides/signing-and-verifying.md#respond-to-a-rejection) holds the same mapping as a program that checks it covers every reason. Selectors use `kind/layer.reason` or `kind/reason`; `*` covers that family. An exact reason overrides its family row. The guide program is authoritative. Adapters wrap the underlying reason in `error.source`; map that. Any `retryable: true` error overrides the table with 503. For `adapter/limit`, 413 is an optional alternative to 400.
326
+ Every failure is a `Result` with a bounded `error`; branch on `error.kind` and `error.reason`. This table is a recommendation, not a requirement. It is derived from the error vocabulary in [error parity](docs/reference/error-parity.md); [error reasons](docs/reference/errors.md) explains each reason and what to do about it, and the [signing guide](docs/guides/signing-and-verifying.md#respond-to-a-rejection) holds the same mapping as a program that checks it covers every reason. Selectors use `kind/layer.reason` or `kind/reason`; `*` covers that family. An exact reason overrides its family row. The guide program is authoritative. Adapters wrap the underlying reason in `error.source`; map that. Any `retryable: true` error overrides the table with 503. For `adapter/limit`, 413 is an optional alternative to 400.
327
327
 
328
328
  The reasoning, stated once: 400 means the message cannot be read, so resending the same bytes cannot help. 401 means the sender did not prove what your policy requires, and a fresh correct signature can. 403 means your configuration refuses a sender it understood. 500 means your own code or configuration is wrong. 503 means the failure is transient or the replay store is in trouble.
329
329
 
@@ -332,7 +332,7 @@ The reasoning, stated once: 400 means the message cannot be read, so resending t
332
332
  | 400 | `core/fields.*`, `core/negotiation.*`, `message/*`, `structured_fields/*`, `signature_base/*`, `digest/*`, `jose/input.*`, `jose/nesting.*`, `core/content.body_unavailable`, `core/content.digest_unsupported`, `core/crypto.signature_base_failed`, `core/input.invalid_message`, `core/input.invalid_request`, `core/input.limit`, `core/input.unsupported_component`, `crypto/invalid_data`, `adapter/invalid_request`, `adapter/limit`, `adapter/body_unavailable`, `adapter/digest_conflict`, `adapter/untrusted_proxy` |
333
333
  | 401 | `core/content.*`, `core/freshness.*`, `core/key.*`, `core/policy.*`, `core/quorum.*`, `core/replay.*`, `discovery/*`, `jose/crypto.*`, `jose/key.*`, `core/crypto.invalid_signature`, `core/fields.missing_signature`, `core/fields.missing_signature_input`, `core/fields.no_web_bot_auth_signature`, `core/fields.unknown_label`, `core/negotiation.negotiation_unfulfilled`, `crypto/invalid_signature`, `custody/invalid_signature`, `custody/decryption_failed`, `adapter/request_rejected`, `adapter/response_rejected` |
334
334
  | 403 | `core/binding.*`, `core/policy.test_key_rejected`, `discovery/revoked_key` |
335
- | 500 | `core/crypto.*`, `core/input.*`, `crypto/*`, `custody/*`, `adapter/*`, `core/binding.invalid_binding`, `core/binding.invalid_verification`, `core/fields.label_in_use`, `core/freshness.invalid_clock`, `core/key.key_resolver_failed`, `core/quorum.ambiguous_key_identity`, `core/replay.commitment_failed`, `discovery/invalid_options`, `discovery/invalid_source`, `discovery/ambiguous_key_source`, `discovery/transport_unavailable`, `jose/crypto.entropy_failure`, `jose/crypto.signer_failed`, `jose/input.invalid_options`, `jose/input.invalid_policy`, `jose/key.key_resolver_failed`, `replay/invalid_claim`, `replay/invalid_options`, `replay/invalid_store` |
335
+ | 500 | `core/crypto.*`, `core/input.*`, `crypto/*`, `custody/*`, `adapter/*`, `core/binding.invalid_binding`, `core/binding.invalid_verification`, `core/fields.label_in_use`, `core/freshness.invalid_clock`, `core/key.key_resolver_failed`, `core/quorum.ambiguous_key_identity`, `core/internal.internal`, `core/replay.commitment_failed`, `discovery/invalid_options`, `discovery/invalid_source`, `discovery/domain_not_found`, `discovery/ambiguous_key_source`, `discovery/transport_unavailable`, `jose/crypto.entropy_failure`, `jose/crypto.signer_failed`, `jose/input.invalid_options`, `jose/input.invalid_policy`, `jose/key.key_resolver_failed`, `replay/invalid_claim`, `replay/invalid_options`, `replay/invalid_store` |
336
336
  | 503 | `replay/*`, `core/replay.store_failed`, `core/replay.store_timeout`, `core/replay.store_unavailable` |
337
337
 
338
338
  For core `agent_unresolved`, `timeout` and `source_unavailable` map to 503; `untrusted_agent` and `revoked_key` map to 403; other details map to 401.
@@ -378,15 +378,16 @@ RequestSeal reports what was cryptographically established and nothing more: the
378
378
  - [Key discovery](docs/guides/key-discovery.md)
379
379
  - [Signed and encrypted tokens (JOSE)](docs/guides/jose.md)
380
380
  - [Key custody](docs/guides/key-custody.md)
381
+ - [Rotating keys](docs/guides/key-rotation.md)
381
382
  - [Testing](docs/guides/testing.md)
382
383
  - [Migrating from http-message-signatures](docs/guides/migrating-from-http-message-signatures.md)
383
384
 
384
385
  API: [generated reference](https://baselabs.github.io/request-seal-typescript/). Build it locally with `npm ci --ignore-scripts && npm run docs`, then open the generated `site-dist/index.html`.
385
386
 
386
- Reference: [signing and verification](docs/reference/sign-and-verify.md), [adapters](docs/reference/adapters.md), [Web Bot Auth](docs/reference/web-bot-auth.md), [replay](docs/reference/replay.md), [discovery](docs/reference/discovery.md), [JOSE](docs/reference/jose.md), [wire APIs](docs/reference/wire.md), [multiple signatures](docs/reference/quorum.md), [extension profiles](docs/reference/profiles.md), [error parity](docs/reference/error-parity.md), and [corpus format](docs/reference/corpus-format.md). Design records: the [threat model](docs/design/threat-model.md) and the [architecture decisions](docs/adr/README.md). To contribute, see [CONTRIBUTING.md](CONTRIBUTING.md).
387
+ Reference: [glossary](docs/reference/glossary.md), [error reasons](docs/reference/errors.md), [signing and verification](docs/reference/sign-and-verify.md), [adapters](docs/reference/adapters.md), [Web Bot Auth](docs/reference/web-bot-auth.md), [replay](docs/reference/replay.md), [discovery](docs/reference/discovery.md), [JOSE](docs/reference/jose.md), [wire APIs](docs/reference/wire.md), [multiple signatures](docs/reference/quorum.md), [extension profiles](docs/reference/profiles.md), [error parity](docs/reference/error-parity.md), [telemetry](docs/reference/telemetry.md), and [corpus format](docs/reference/corpus-format.md). Design record: the [threat model](docs/design/threat-model.md). To contribute, see [CONTRIBUTING.md](CONTRIBUTING.md).
387
388
 
388
389
  ## Compatibility
389
390
 
390
- The core runs on Node 22.12.0, 24.21.0, and 26.11.1, Deno 2.5.6, and Bun 1.3.14. Fetch signs on those runtimes, but Fetch verification supports only Deno 2.5.6. HTTPS discovery requires Node; use caller-owned snapshots elsewhere. In browsers (Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2), the Fetch adapter signs only. Core `verify` checks a caller-built `Message` under an explicit policy without using the Fetch adapter. See the [runtime matrix](docs/reference/runtime-matrix.md) for exact tested versions, permissions, and limitations.
391
+ The core runs on Node 22.12.0, 22.23.1, 24.21.0, and 26.11.1, Deno 2.5.6, and Bun 1.3.14. Fetch signs on those runtimes, but Fetch verification supports only Deno 2.5.6. HTTPS discovery requires Node; use caller-owned snapshots elsewhere. In browsers (Chromium 156.0.8078.4, Firefox 157.0, WebKit 27.2), the Fetch adapter signs only. Core `verify` checks a caller-built `Message` under an explicit policy without using the Fetch adapter. See the [runtime matrix](docs/reference/runtime-matrix.md) for exact tested versions, permissions, and limitations.
391
392
 
392
393
  Crypto, custody, and error vocabulary follow RequestSeal for Elixir 0.4.1; conformance uses its byte-preserved 0.4.0 corpus. See [the corpus pin](CONTRIBUTING.md) and [replay protection](docs/reference/replay.md).
@@ -59,7 +59,14 @@ export type ClientResponse = Readonly<{
59
59
  }>;
60
60
  /** Capture and retain a Node incoming request before body parsing. */
61
61
  export declare function captureRequest(req: unknown, options?: unknown): Promise<Result<Capture>>;
62
- /** Install early Node request capture on a server with explicit limits. */
62
+ /**
63
+ * Install early Node request capture on a server with explicit limits.
64
+ *
65
+ * Options are `maxBytes`, `timeoutMs` (1 to 300,000 ms, default 5,000), and
66
+ * `trustedProxies`. Each request is captured under its own fresh `timeoutMs` deadline.
67
+ * A `context` is refused with `invalid_options`: one Context is one absolute deadline,
68
+ * and the registration outlives it.
69
+ */
63
70
  export declare function capture(server: unknown, options?: unknown): Result<Readonly<{
64
71
  /** Close this capability and release its retained state. */
65
72
  close: () => Result<true>;
@@ -12,7 +12,8 @@ import { verifyWebBotAuth as coreVerifyWebBotAuth } from "../../web-bot-auth/ver
12
12
  import { parseAddress } from "../../discovery/address.js";
13
13
  import { isPolicy } from "../../policy.js";
14
14
  import { isAcceptSignature } from "../../accept-signature.js";
15
- import { run } from "../../context.js";
15
+ import { run, withDeadline } from "../../context.js";
16
+ import { timeoutMs as validTimeout } from "../../numbers.js";
16
17
  import { err, ok } from "../../result.js";
17
18
  import { MESSAGE_MAX_FIELD_BYTES, MESSAGE_MAX_FIELDS_BYTES, MESSAGE_MAX_OCCURRENCES, } from "../../constants.js";
18
19
  import * as S from "../support.js";
@@ -428,12 +429,26 @@ export async function captureRequest(req, options = {}) {
428
429
  return S.caught(e, "node-http", "capture", "invalid_options");
429
430
  }
430
431
  }
431
- /** Install early Node request capture on a server with explicit limits. */
432
+ /**
433
+ * Install early Node request capture on a server with explicit limits.
434
+ *
435
+ * Options are `maxBytes`, `timeoutMs` (1 to 300,000 ms, default 5,000), and
436
+ * `trustedProxies`. Each request is captured under its own fresh `timeoutMs` deadline.
437
+ * A `context` is refused with `invalid_options`: one Context is one absolute deadline,
438
+ * and the registration outlives it.
439
+ */
432
440
  export function capture(server, options = {}) {
433
441
  try {
434
- const a = captureOptions(options, ["maxBytes", "context", "trustedProxies"], "attach");
442
+ // A Context is one absolute deadline, so a registration that outlives it takes
443
+ // a per-request timeoutMs instead; each request gets its own fresh deadline.
444
+ const a = captureOptions(options, ["maxBytes", "timeoutMs", "trustedProxies"], "attach");
435
445
  S.maximum(a, "node-http", "attach");
436
- S.context(a, "node-http", "attach");
446
+ const timeout = validTimeout(Object.hasOwn(a, "timeoutMs") ? a.timeoutMs : 5000);
447
+ S.need(timeout.ok, "node-http", "attach", "invalid_options");
448
+ const perRequest = Object.create(null);
449
+ for (const key of ["maxBytes", "trustedProxies"])
450
+ if (Object.hasOwn(a, key))
451
+ perRequest[key] = a[key];
437
452
  S.need(server !== null &&
438
453
  typeof server === "object" &&
439
454
  "prependListener" in server &&
@@ -441,7 +456,8 @@ export function capture(server, options = {}) {
441
456
  "removeListener" in server &&
442
457
  typeof server.removeListener === "function", "node-http", "attach", "invalid_options");
443
458
  const listener = (req) => {
444
- void captureRequest(req, a);
459
+ const context = withDeadline({ timeoutMs: timeout.ok ? timeout.value : 5000 });
460
+ void captureRequest(req, context.ok ? Object.freeze({ ...perRequest, context: context.value }) : perRequest);
445
461
  };
446
462
  server.prependListener("request", listener);
447
463
  const close = () => {
@@ -1,10 +1,10 @@
1
1
  /** The public package version. */
2
- export declare const VERSION = "0.2.0";
2
+ export declare const VERSION = "0.4.0";
3
3
  /** The immutable public conformance corpus release and index digest. */
4
4
  export declare const CORPUS_PIN: Readonly<{
5
- tag: "v0.4.0";
6
- commit: "b2392871933d99641a7cb2ca2726aefd20081dee";
7
- indexSha256: "f37435eca377666ab45c2b8f580287263145268e03d9224c76e324e4afaded6f";
5
+ tag: "v0.5.2";
6
+ commit: "53b6690ccd5036d58b280d08cf0cacc6427262fe";
7
+ indexSha256: "ceb30bf713a6e34e6a15847b658a618d8f7568ee6a6c07bfbbf58fa5d143995e";
8
8
  }>;
9
9
  /** Default bounded Structured Fields parser limits. */
10
10
  export declare const STRUCTURED_FIELDS_LIMITS: Readonly<{
package/dist/constants.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** The public package version. */
2
- export const VERSION = '0.2.0';
2
+ export const VERSION = '0.4.0';
3
3
  /** The immutable public conformance corpus release and index digest. */
4
- export const CORPUS_PIN = Object.freeze({ tag: 'v0.4.0', commit: 'b2392871933d99641a7cb2ca2726aefd20081dee', indexSha256: 'f37435eca377666ab45c2b8f580287263145268e03d9224c76e324e4afaded6f' });
4
+ export const CORPUS_PIN = Object.freeze({ tag: 'v0.5.2', commit: '53b6690ccd5036d58b280d08cf0cacc6427262fe', indexSha256: 'ceb30bf713a6e34e6a15847b658a618d8f7568ee6a6c07bfbbf58fa5d143995e' });
5
5
  // RequestSeal.StructuredFields ceilings at v0.4.0 (b239287).
6
6
  /** Default bounded Structured Fields parser limits. */
7
7
  export const STRUCTURED_FIELDS_LIMITS = Object.freeze({ maxBytes: 65_536, maxMembers: 1024, maxInnerItems: 256, maxParameters: 256, maxKeyBytes: 256, maxValueBytes: 16_384, maxNodes: 4096 });
@@ -25,6 +25,10 @@ export type Spec = Readonly<{
25
25
  unwrap: boolean;
26
26
  }>;
27
27
  export declare function algorithms(): Result<readonly HttpAlgorithm[]>;
28
+ /** The JOSE name for a source-asserted alg in either registry spelling; undefined outside both (PS256 and PS384 exist only as JOSE names). */
29
+ export declare function assertedJose(value: string): JwsAlgorithm | undefined;
30
+ /** The JOSE name for either allowlist spelling; total over every validated selector. */
31
+ export declare function joseId(value: SigningAlgorithm): JwsAlgorithm;
28
32
  export declare function resolveAlgorithm(value: unknown, allowUnwrap?: boolean): Result<Spec>;
29
33
  export declare function selection(spec: Spec): CustodyAlgorithm;
30
34
  export declare function importParameters(spec: Spec): {
@@ -90,6 +90,18 @@ const http = Object.freeze({
90
90
  export function algorithms() {
91
91
  return ok(Object.freeze(Object.keys(http)));
92
92
  }
93
+ /** The JOSE name for a source-asserted alg in either registry spelling; undefined outside both (PS256 and PS384 exist only as JOSE names). */
94
+ export function assertedJose(value) {
95
+ return Object.hasOwn(http, value)
96
+ ? http[value]
97
+ : Object.hasOwn(jws, value)
98
+ ? value
99
+ : undefined;
100
+ }
101
+ /** The JOSE name for either allowlist spelling; total over every validated selector. */
102
+ export function joseId(value) {
103
+ return typeof value === 'string' ? http[value] : value.jws;
104
+ }
93
105
  export function resolveAlgorithm(value, allowUnwrap = false) {
94
106
  const invalid = () => err(cryptoError('unsupported_algorithm'));
95
107
  try {
@@ -1,17 +1,25 @@
1
1
  import { type Context } from "../context.ts";
2
2
  import { PublicKey } from "../public_key.ts";
3
- import { type HttpAlgorithm, type SigningAlgorithm } from "../crypto/algorithm.ts";
3
+ import { type JwsAlgorithm, type SigningAlgorithm } from "../crypto/algorithm.ts";
4
4
  import { type Result } from "../result.ts";
5
5
  import { type DiscoverySource } from "./source.ts";
6
6
  /** Discovery proof status. @inline */
7
7
  export type Proof = "signed" | "unsigned" | "not_applicable";
8
+ /** A conditional revalidation outcome: the stored representation is unchanged (RFC 9111 Section 4.3.4). */
9
+ export type NotModified = Readonly<{
10
+ notModified: true;
11
+ etag: string;
12
+ fetchedAt: number;
13
+ expiresAt: number;
14
+ }>;
8
15
  /** A branded resolved public key with origin, proof, and expiration facts. */
9
16
  export type Resolution = Readonly<{
10
17
  key: PublicKey;
11
18
  keyId: string;
12
19
  thumbprint: string;
13
20
  algorithm: SigningAlgorithm;
14
- assertedAlgorithm: HttpAlgorithm | null;
21
+ /** The source's alg assertion normalized once at import to its JOSE name. */
22
+ assertedAlgorithm: JwsAlgorithm | null;
15
23
  origin: string;
16
24
  sourceType: DiscoverySource["type"];
17
25
  location: string;
@@ -34,6 +42,8 @@ export type KeySet = Readonly<{
34
42
  expiresAt: number;
35
43
  revision: string;
36
44
  proof: Proof;
45
+ /** The source's ETag validator, if it sent one; the cache revalidates with it. */
46
+ etag: string | null;
37
47
  /** Resolve an eligible key by identifier, allowed algorithms, and current time. */
38
48
  lookup: (keyId: unknown, algorithms: unknown, now?: number) => Promise<Result<Resolution>>;
39
49
  }>;
@@ -1,7 +1,7 @@
1
1
  import { remainingMs } from "../context.js";
2
2
  import { decodeJson } from "../json.js";
3
3
  import { PublicKey } from "../public_key.js";
4
- import { resolveAlgorithm, algorithms, selection, } from "../crypto/algorithm.js";
4
+ import { resolveAlgorithm, assertedJose, joseId, selection, } from "../crypto/algorithm.js";
5
5
  import { digest } from "../crypto/subtle.js";
6
6
  import { encodeBase64Url } from "../bytes.js";
7
7
  import { ok } from "../result.js";
@@ -59,7 +59,8 @@ export async function lookup(set, kid, allowed, now = systemClock(), transient =
59
59
  isResolution(entry) &&
60
60
  now < entry.expiresAt &&
61
61
  !set.source.revoked.includes(entry.thumbprint), "unknown_key");
62
- const algorithm = choices.find((a) => (entry.assertedAlgorithm === null || entry.assertedAlgorithm === a) &&
62
+ const algorithm = choices.find((a) => (entry.assertedAlgorithm === null ||
63
+ entry.assertedAlgorithm === joseId(a)) &&
63
64
  entry.key.bind(a).ok);
64
65
  ensure(algorithm, "unknown_key");
65
66
  return ok(resolution({ ...entry, algorithm, revision: set.revision }));
@@ -125,14 +126,34 @@ export async function bodyKeys(doc, source, now, location = source.location, con
125
126
  !Array.isArray(input) &&
126
127
  Object.keys(input).length <= 32, "invalid_key_set");
127
128
  const j = input, asserted = j.alg;
129
+ // Leaked private members are a publisher blunder the whole set rejects
130
+ // loudly, before any skip decision: skipping must never hide them.
131
+ ensure(!["d", "p", "q", "dp", "dq", "qi", "oth", "k"].some((k) => Object.hasOwn(j, k)), "invalid_key_set");
132
+ // A key whose alg names an algorithm this verifier does not use — an
133
+ // encryption key, or a signing name outside both registries — cannot
134
+ // satisfy any signature policy: skip it and keep the rest of the set.
135
+ if (typeof asserted === "string" && assertedJose(asserted) === undefined)
136
+ continue;
128
137
  ensure(asserted === undefined ||
129
138
  asserted === null ||
130
- (typeof asserted === "string" &&
131
- unwrap(algorithms(), "invalid_key_set").includes(asserted)), "invalid_key_set");
139
+ typeof asserted === "string", "invalid_key_set");
140
+ // RFC 7517 sources assert standard JOSE names; either spelling normalizes
141
+ // once, here, to the JOSE name, which exists for every registry entry.
142
+ const normalized = typeof asserted === "string" ? assertedJose(asserted) : undefined;
132
143
  const imported = Object.assign(Object.create(null), j);
133
- if (typeof asserted === "string")
134
- imported.alg = unwrap(resolveAlgorithm(asserted), "invalid_key_set").jose;
135
- const key = unwrap(await PublicKey.import(imported), "invalid_key_set");
144
+ if (normalized !== undefined)
145
+ imported.alg = normalized;
146
+ const attempt = await PublicKey.import(imported);
147
+ if (!attempt.ok) {
148
+ // A well-formed key this verifier cannot import for signature work (an
149
+ // unknown kty or curve, RFC 7517 Section 5) is skipped like an
150
+ // out-of-registry alg; other import failures stay set rejections.
151
+ if (attempt.error.kind !== "crypto" || attempt.error.reason !== "invalid_key")
152
+ ensure(false, "invalid_key_set");
153
+ ensure(!context || remainingMs(context) > 0, "deadline_exceeded");
154
+ continue;
155
+ }
156
+ const key = attempt.value;
136
157
  ensure(!context || remainingMs(context) > 0, "deadline_exceeded");
137
158
  const thumbprint = unwrap(await key.thumbprint(), "invalid_key_set");
138
159
  ensure(!context || remainingMs(context) > 0, "deadline_exceeded");
@@ -143,14 +164,15 @@ export async function bodyKeys(doc, source, now, location = source.location, con
143
164
  j.kid === thumbprint, "invalid_key_set");
144
165
  const nbf = Object.hasOwn(j, "nbf") ? keyTime(j.nbf) : null, exp = Object.hasOwn(j, "exp") ? keyTime(j.exp) : null;
145
166
  const publicJwk = unwrap(key.jwk(), "invalid_key_set");
146
- const algorithm = (asserted ??
147
- (publicJwk.kty === "OKP"
167
+ const algorithm = normalized === undefined
168
+ ? (publicJwk.kty === "OKP"
148
169
  ? "ed25519"
149
170
  : publicJwk.kty === "EC"
150
171
  ? publicJwk.crv === "P-256"
151
172
  ? "ecdsa-p256-sha256"
152
173
  : "ecdsa-p384-sha384"
153
- : "rsa-pss-sha512"));
174
+ : "rsa-pss-sha512")
175
+ : { jws: normalized };
154
176
  if (key.bind(algorithm).ok &&
155
177
  (nbf === null || now >= nbf) &&
156
178
  (exp === null || now < exp) &&
@@ -161,7 +183,7 @@ export async function bodyKeys(doc, source, now, location = source.location, con
161
183
  keyId: kid,
162
184
  thumbprint,
163
185
  algorithm,
164
- assertedAlgorithm: (asserted ?? null),
186
+ assertedAlgorithm: normalized ?? null,
165
187
  origin: sourceOrigin(sourceUrl(location)),
166
188
  sourceType: source.type,
167
189
  location,
@@ -1,12 +1,16 @@
1
1
  import { type Result } from "../result.ts";
2
2
  import { type Context } from "../context.ts";
3
3
  import { type DiscoverySource } from "./source.ts";
4
- import { type KeySet, type Resolution } from "./body.ts";
5
- /** A caller-owned discovery transport callback. */
4
+ import { type KeySet, type NotModified, type Resolution } from "./body.ts";
5
+ /** A caller-owned discovery transport callback. With `validators`, an unchanged source answers 304 and the callback returns a `NotModified` outcome instead of a key set (RFC 9111 Section 4.3.4). */
6
6
  export type DiscoveryFetch = (source: DiscoverySource, options: {
7
7
  clock: () => number;
8
8
  context: Context;
9
- }) => Promise<Result<KeySet>>;
9
+ validators?: Readonly<{
10
+ etag: string;
11
+ lifetime: number;
12
+ }>;
13
+ }) => Promise<Result<KeySet | NotModified>>;
10
14
  /** A caller-owned bounded cache of discovery key sets and failures. */
11
15
  export type DiscoveryCache = Readonly<{
12
16
  /** Refresh a source from an explicit snapshot or configured fetch callback. */
@@ -20,6 +24,12 @@ export type DiscoveryCache = Readonly<{
20
24
  /** Close this capability and release its retained state. */
21
25
  close: () => Result<true>;
22
26
  }>;
27
+ /**
28
+ * Internal to the resolver bridge, not exported from the package: resolve with the
29
+ * cache's own clock for fetching, storing, negative caching, and entry freshness, and
30
+ * decide the key's freshness on `decision`.
31
+ */
32
+ export declare function resolveOnClock(cache: DiscoveryCache, s: unknown, kid: unknown, input: unknown, decision: () => number): Promise<Result<Resolution>>;
23
33
  /** Check the private brand of a discovery cache. */
24
34
  export declare function isDiscoveryCache(v: unknown): v is DiscoveryCache;
25
35
  /** Validate cache options and create a branded discovery cache. */