@request-seal/core 0.1.0 → 0.3.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 (128) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +342 -114
  3. package/dist/accept-signature.d.ts +11 -0
  4. package/dist/accept-signature.js +7 -0
  5. package/dist/adapters/fetch/index.d.ts +10 -0
  6. package/dist/adapters/fetch/index.js +21 -4
  7. package/dist/adapters/fetch/runtime.d.ts +1 -0
  8. package/dist/adapters/fetch/runtime.js +1 -0
  9. package/dist/adapters/node-http/index.d.ts +23 -0
  10. package/dist/adapters/node-http/index.js +28 -8
  11. package/dist/brand.d.ts +5 -0
  12. package/dist/brand.js +12 -0
  13. package/dist/bytes.d.ts +4 -0
  14. package/dist/bytes.js +5 -0
  15. package/dist/constants.d.ts +64 -4
  16. package/dist/constants.js +62 -2
  17. package/dist/context.d.ts +4 -0
  18. package/dist/context.js +3 -0
  19. package/dist/crypto/algorithm.d.ts +9 -0
  20. package/dist/crypto/algorithm.js +12 -0
  21. package/dist/crypto/index.d.ts +12 -2
  22. package/dist/crypto/index.js +34 -11
  23. package/dist/crypto/options.d.ts +3 -0
  24. package/dist/crypto/options.js +15 -0
  25. package/dist/crypto/primitive.d.ts +7 -3
  26. package/dist/crypto/primitive.js +36 -10
  27. package/dist/custody/content_key.d.ts +4 -0
  28. package/dist/custody/content_key.js +4 -2
  29. package/dist/custody/index.d.ts +2 -0
  30. package/dist/custody/index.js +5 -1
  31. package/dist/custody/local.d.ts +17 -1
  32. package/dist/custody/local.js +10 -5
  33. package/dist/custody/ssh-agent/index.d.ts +2 -0
  34. package/dist/custody/ssh-agent/index.js +11 -181
  35. package/dist/custody/ssh-agent/port.d.ts +19 -0
  36. package/dist/custody/ssh-agent/port.js +183 -0
  37. package/dist/digest.d.ts +22 -0
  38. package/dist/digest.js +15 -0
  39. package/dist/discovery/body.d.ts +19 -2
  40. package/dist/discovery/body.js +36 -11
  41. package/dist/discovery/cache.d.ts +21 -2
  42. package/dist/discovery/cache.js +105 -39
  43. package/dist/discovery/headers.d.ts +3 -0
  44. package/dist/discovery/headers.js +27 -1
  45. package/dist/discovery/index.d.ts +13 -1
  46. package/dist/discovery/index.js +31 -7
  47. package/dist/discovery/node-connection.d.ts +1 -1
  48. package/dist/discovery/node-connection.js +2 -2
  49. package/dist/discovery/node-trust.d.ts +2 -0
  50. package/dist/discovery/node-trust.js +9 -0
  51. package/dist/discovery/node.d.ts +9 -1
  52. package/dist/discovery/node.js +150 -26
  53. package/dist/discovery/proof.js +2 -2
  54. package/dist/discovery/snapshot.js +3 -0
  55. package/dist/discovery/source.d.ts +7 -0
  56. package/dist/discovery/source.js +5 -0
  57. package/dist/discovery/support.js +2 -3
  58. package/dist/errors/parity.d.ts +44 -3
  59. package/dist/errors/parity.js +45 -4
  60. package/dist/errors/types.d.ts +17 -3
  61. package/dist/errors/types.js +16 -2
  62. package/dist/index.d.ts +1 -0
  63. package/dist/index.js +1 -0
  64. package/dist/jose/header.d.ts +1 -0
  65. package/dist/jose/jwe.d.ts +9 -0
  66. package/dist/jose/jwe.js +17 -3
  67. package/dist/jose/jws.d.ts +11 -0
  68. package/dist/jose/jws.js +14 -2
  69. package/dist/jose/key-management.d.ts +11 -0
  70. package/dist/jose/key-management.js +13 -2
  71. package/dist/jose/nested.d.ts +6 -0
  72. package/dist/jose/nested.js +32 -2
  73. package/dist/json.d.ts +7 -0
  74. package/dist/key_handle.d.ts +12 -3
  75. package/dist/key_handle.js +32 -25
  76. package/dist/key_identity.d.ts +7 -0
  77. package/dist/key_identity.js +10 -2
  78. package/dist/message/index.d.ts +24 -0
  79. package/dist/message/index.js +22 -1
  80. package/dist/numbers.d.ts +6 -0
  81. package/dist/numbers.js +5 -0
  82. package/dist/policy.d.ts +27 -0
  83. package/dist/policy.js +42 -21
  84. package/dist/profile.d.ts +36 -0
  85. package/dist/profile.js +215 -0
  86. package/dist/public_key.d.ts +11 -2
  87. package/dist/public_key.js +31 -8
  88. package/dist/quorum/evaluation.d.ts +1 -1
  89. package/dist/quorum/evaluation.js +18 -4
  90. package/dist/quorum/verification.d.ts +2 -0
  91. package/dist/quorum/verification.js +1 -0
  92. package/dist/quorum.d.ts +9 -0
  93. package/dist/quorum.js +7 -1
  94. package/dist/replay/claim.d.ts +5 -0
  95. package/dist/replay/claim.js +9 -1
  96. package/dist/replay/index.d.ts +6 -0
  97. package/dist/replay/index.js +13 -1
  98. package/dist/replay/postgres.d.ts +3 -0
  99. package/dist/replay/postgres.js +7 -0
  100. package/dist/replay/receipt.d.ts +1 -0
  101. package/dist/replay/receipt.js +1 -0
  102. package/dist/replay/store.d.ts +1 -0
  103. package/dist/replay/store.js +1 -0
  104. package/dist/replay-port.d.ts +6 -1
  105. package/dist/result.d.ts +3 -0
  106. package/dist/result.js +2 -0
  107. package/dist/sign.d.ts +26 -0
  108. package/dist/sign.js +25 -3
  109. package/dist/signature-base/index.d.ts +2 -0
  110. package/dist/signature-base/index.js +5 -1
  111. package/dist/signature-fields.d.ts +5 -1
  112. package/dist/signature-fields.js +9 -1
  113. package/dist/structured-fields/index.d.ts +21 -0
  114. package/dist/structured-fields/index.js +14 -1
  115. package/dist/verification.d.ts +5 -1
  116. package/dist/verification.js +9 -6
  117. package/dist/verify-quorum.d.ts +1 -0
  118. package/dist/verify-quorum.js +30 -8
  119. package/dist/verify.d.ts +23 -0
  120. package/dist/verify.js +46 -14
  121. package/dist/web-bot-auth/index.d.ts +3 -0
  122. package/dist/web-bot-auth/index.js +3 -0
  123. package/dist/web-bot-auth/policy.d.ts +8 -0
  124. package/dist/web-bot-auth/policy.js +8 -4
  125. package/dist/web-bot-auth/verification.d.ts +8 -0
  126. package/dist/web-bot-auth/verification.js +4 -2
  127. package/dist/web-bot-auth/verify.js +2 -2
  128. package/package.json +29 -6
package/CHANGELOG.md CHANGED
@@ -2,11 +2,59 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.0 - October 17, 2026
6
+
7
+ 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.
8
+ - 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).
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+ - 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`.
16
+ - 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.
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+ - 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.
21
+ - 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.
22
+ - Test on Node 22.23.1 in CI, beside 22.12.0, 24.21.0, and 26.11.1 (Linux only).
23
+ - 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`.
24
+ - 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.
25
+ - 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.
26
+ - 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`.
27
+ - 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`.
28
+ - 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.
29
+ - 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.
30
+ - Document supported versions and how to verify release provenance with `npm audit signatures` in SECURITY.md.
31
+ - 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.
32
+
33
+ ### Migrating from 0.2.x
34
+ - `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.
35
+ - 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.
36
+ - `@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`.
37
+
38
+ ## 0.2.0 - October 11, 2026
39
+ - 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.
40
+ - 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.
41
+ - Sign Requests in runtimes without a Request body accessor (Firefox) by reading the already materialized body with the byte bound applied after the copy; a Request that merely hides its body stream still goes through the bounded reader. Fetch verification eligibility is checked before any Request is constructed, so an ineligible runtime never disturbs the caller's body. Node discovery reports `discovery/transport_unavailable` only when the runtime denies access to OS trust roots; other trust-store errors keep their existing reasons.
42
+ - Test on Node 22.12.0, 24.21.0, and 26.11.1, Deno 2.5.6, and Bun 1.3.14, plus Chromium 156.0.8078.4, Firefox 157.0, and WebKit 27.2; document exact runtime limitations and OS trust permissions.
43
+ - Return bounded `discovery/transport_unavailable` when system-root access is denied. Refuse ineligible Fetch verified delivery before consuming request bytes.
44
+ - Try IPv6 discovery addresses before IPv4, stable within each family, and remove duplicate answers before the 64-address bound, as RequestSeal does. A host whose IPv6 route silently drops traffic now spends the deadline on the IPv6 address first.
45
+ - Add the Profile extension surface with bounded package stamps, shared selected-label verification, profile-bound replay facts, and execution of all nine profile corpus cases.
46
+ - Widen `Verification.profile` and `ReplayFacts.profile` to include extension stamps; exhaustive narrowing consumers must handle the two-string extension name tuple. `ProfileStamp<MetadataKey>` restricts named metadata to scalar values while runtime validation enforces integer, Unicode, and size bounds.
47
+ - Encode explicit verification facts as UTF-8, convert null stamp metadata to the reference's empty string, and canonicalize whole-stamp key order for replay commitments. Bound name parts and keys to 255 Unicode code points and reject lone surrogates in every stamp string.
48
+ - Render Deno inspection of Verifications, Web Bot Auth envelopes and policies, discovery values, key handles, public keys, key identities, content keys and JOSE results through the callback Deno passes to `Deno.customInspect`; Deno previously printed `[object Object]`. Each still shows only its redacted view.
49
+ - Align crypto and custody with RequestSeal for Elixir 0.4.1: add trailing per-call `maxBytes` options (1–16,777,216; default 1,048,576), validate options before key and algorithm checks, enforce direct callback ceilings, and forward larger SSH-agent requests with bounded peer errors. Invalid byte options return `invalid_options`: crypto for `Crypto.sign`, `Crypto.verify` and `publicKey.verify`, custody for `KeyHandle.sign`, `KeyHandle.verify` and `KeyHandle.unwrap`, which accepts no `maxBytes`. `publicKey.verify` takes the same trailing options. `Crypto.sign`, `Crypto.verify` and `publicKey.verify` check the data size before key validity, as the reference does. Expose `Crypto.maxBytesCeiling()` and the exported `CRYPTO_MAX_BYTES_CEILING` and `CRYPTO_DEFAULT_MAX_BYTES` constants. A caller custodian's fourth argument carries the ceiling; custodians enforce their own input limit and pass the ceiling when delegating. The ssh-agent retains its practical payload limit, including protocol overhead: a peer refusal returns `custodian_protocol` through `KeyHandle.sign` and crypto `invalid_key` through `Crypto.sign`. Keep Message, Body, JWS, adapter, and SignatureBase limits fixed. Align the error vocabulary with RequestSeal for Elixir 0.4.1; retain the unchanged 0.4.0 conformance corpus.
50
+
51
+ ## 0.1.0 - October 10, 2026
52
+ - Published to npm manually, without provenance, from commit d175327. It predates parity with RequestSeal for Elixir 0.4.1, the Profile extension surface and its nine conformance cases, the reciprocal exchange, the browser lanes and the developer documentation.
5
53
  - Align HTTP handle signing and the error vocabulary with RequestSeal 0.4.0: explicit `signingTimeout` bounds, `signer_algorithm_mismatch` with its exact message, and `signing_failed` with a bounded custody source on both forms. Handle deadlines start immediately before custody, after signature-base construction, and are capped at `signingTimeout` (default 5,000 ms) even under a longer caller Context. Generated-spec KeyHandle failures now report `signing_failed` (previously `signer_failed`); direct `handle.sign` malformed output now reports `invalid_signing_output` (previously `custodian_failure`). Add nullable core `message` and `source` fields; preserve callable `signer_failed` behavior. Re-pin the error extraction, shared corpus, and replay peer.
6
54
  - 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.
7
55
  - Add captured Node HTTP Web Bot Auth verification, shared attempt storage, and executable signed TLS directory examples.
8
56
  - Reject concurrent capture consumers and retained length mismatches; derive HTTP/2 scheme from TLS and reject conflicting Host/authority.
9
- - Add captured-request quorum verification with shared replay and single-verification guards.
57
+ - 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.
10
58
  - Apply redirect method/body rules, validate redirect policy before delivery, remove Location fragments, and report actual client attempts.
11
59
  - 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.
12
60
  - Deliver response signatures with their exact buffered bytes, use one clock for Date and created, and omit prohibited lengths.
package/README.md CHANGED
@@ -1,165 +1,393 @@
1
- **Status:** development · **Kind:** overview · **Updated:** October 10, 2026 · **Governed by:** the public API and shared conformance corpus · **Review when:** package scope or installation 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
 
5
- RequestSeal will provide standards-based HTTP message signatures and JOSE for TypeScript applications using WebCrypto. The first npm release is pending.
5
+ RequestSeal signs and verifies HTTP messages ([RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html)) and compact JOSE tokens in TypeScript, for services and clients that need to know who sent a request and that its bytes arrived unchanged. It uses WebCrypto, has no runtime dependencies, and runs on Node, Deno, and Bun.
6
6
 
7
- After publication:
7
+ ## Install
8
8
 
9
9
  ```sh
10
10
  npm install @request-seal/core
11
11
  ```
12
12
 
13
- For development, use Node 22.12.0 or newer:
13
+ See [CHANGELOG.md](CHANGELOG.md) for published versions and unreleased changes. The package is ESM. It needs Node 22.12.0 or newer, and the core API also runs on Deno and Bun (see Compatibility below). In TypeScript, use `nodenext` or `bundler` module resolution so subpath imports such as `@request-seal/core/fetch` resolve. Complete programs generate keys, close their loopback servers, and assert results; the labeled verification excerpts need the setup in their expanded programs.
14
14
 
15
- ```sh
16
- npm ci --ignore-scripts
17
- npm run check
15
+ Save a complete program as `example.ts` in a project whose `package.json` has `"type": "module"`. On Node 22, run `node --experimental-strip-types example.ts`; on Node 24 and later, run `node example.ts`. For programs marked Deno, run `deno run --allow-read --allow-net=127.0.0.1,jsr.io example.ts`; for Bun, run `bun run example.ts`.
16
+
17
+ ## Sign a request
18
+
19
+ `signedFetch` is `fetch` plus a signature. It signs every attempt afresh, so a retry is never a replay.
20
+
21
+ ```ts
22
+ import assert from "node:assert/strict";
23
+ import { createServer } from "node:http";
24
+ import { Custody } from "@request-seal/core";
25
+ import { signedFetch } from "@request-seal/core/fetch";
26
+ const key = await Custody.local("hmac-sha256", { hmac: crypto.getRandomValues(new Uint8Array(32)) });
27
+ const server = createServer((req, res) => res.end(String(req.headers["signature-input"]))).listen(0, "127.0.0.1");
28
+ await new Promise((ready) => server.once("listening", ready));
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}/reports`, { method: "GET" }, { spec, signer: key.ok ? key.value : assert.fail() });
31
+ assert.ok(sent.ok && (await sent.value.response.text()).startsWith('sig=("@method" "@authority" "@path")')); server.close();
18
32
  ```
19
33
 
20
- The current package contains results, bounded errors, byte codecs, ordered JSON, numeric guards, deadline contexts, option snapshots, Structured Fields, lossless HTTP messages, RFC 9421 signature-base construction, cryptographic verification, public key formats, caller-owned local custody, HTTP signing and verification, explicit policies, content digests, quorum verification and Accept-Signature negotiation, compact JWS/JWE with one-level nesting, discovery snapshots and caches, and an explicit HTTPS discovery transport through `@request-seal/core/discovery-node`. See [multiple signatures](docs/reference/quorum.md), [JOSE APIs](docs/reference/jose.md), [signing and verification](docs/reference/sign-and-verify.md), [wire APIs](docs/reference/wire.md), [discovery](docs/reference/discovery.md), [error parity](docs/reference/error-parity.md), and [corpus format](docs/reference/corpus-format.md).
34
+ Next: [getting started](docs/guides/getting-started.md) for a core sign/verify round trip, or [Node HTTP](docs/guides/node-http.md) for server verification. The Fetch adapter cannot verify `@path`; see the [Fetch guide](docs/guides/fetch.md#verify-incoming-requests).
35
+
36
+ ## Verify an RFC 9421 request
37
+
38
+ Build an explicit policy, then call `verify(message, policy, { label: "sig" })`. This bodyless example signs a GET, verifies it with a configured key, and rejects a changed method. The [strict RFC 9421 recipe](#strict-rfc-9421) below also checks a body digest and rejects a second use.
21
39
 
22
40
  ```ts
23
- import { Custody, Crypto } from "@request-seal/core";
24
-
25
- // Generate example material; applications supply their own private PEM.
26
- const pair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
27
- const der = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey));
28
- const privatePem = "-----BEGIN PRIVATE KEY-----\n" +
29
- btoa(Array.from(der, b => String.fromCharCode(b)).join("")) +
30
- "\n-----END PRIVATE KEY-----";
31
- der.fill(0);
32
- const messageBytes = new TextEncoder().encode("RequestSeal example");
33
- const imported = await Custody.local("ed25519", privatePem);
34
- if (imported.ok) {
35
- const handle = imported.value;
36
- try {
37
- const signature = await Crypto.sign("ed25519", handle, messageBytes);
38
- if (signature.ok) {
39
- const verified = await Crypto.verify("ed25519", handle, signature.value, messageBytes);
40
- console.log(verified.ok);
41
- }
42
- } finally {
43
- await handle.release();
44
- }
45
- }
41
+ import assert from "node:assert/strict";
42
+ import { Custody, Message, Policy, ok, sign, verify, type Result } from "@request-seal/core";
43
+ const must = <T>(r: Result<T>): T => { assert.ok(r.ok); return (r as { value: T }).value; };
44
+ const key = must(await Custody.local("hmac-sha256", { hmac: crypto.getRandomValues(new Uint8Array(32)) }));
45
+ const components = '("@method" "@authority" "@path")';
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/documents", [], "")), { label: "sig", components, algorithm: "hmac-sha256", expiresIn: 60 }, key));
48
+ must(await verify(signed, policy, { label: "sig" }));
49
+ const altered = must(Message.create({ ...signed, method: "DELETE" }));
50
+ const rejected = await verify(altered, policy, { label: "sig" });
51
+ assert.ok(!rejected.ok && rejected.error.kind === "core" && rejected.error.reason === "invalid_signature");
46
52
  ```
47
53
 
48
- HTTP tokens and JWS selectors are separate: use `{jws: "EdDSA"}` explicitly for
49
- a JWS-bound handle. Handle methods accept a `withDeadline()` context.
50
- `PublicKey.import()` accepts public JWK, PEM, or SPKI DER; public exports preserve
51
- metadata restrictions and thumbprints follow RFC 7638. HMAC identities are optional
52
- caller-supplied nonsecret values, never hashes of secrets. RSA-OAEP handles unwrap
53
- into an AES operation capability; they return neither key bytes nor CryptoKey
54
- references. See the [custody threat model](docs/design/threat-model.md).
54
+ ## Verify a Web Bot Auth request
55
55
 
56
- ## HTTP signing, verification, and digests
56
+ [Web Bot Auth](docs/guides/web-bot-auth.md) lets automated agents sign their requests so a site can tell who is calling. A Web Bot Auth signature covers `@authority` by default, not the body. To authenticate the body, the agent signs `content-digest` and the verification policy requires content checking; the excerpts below do not. Verification trusts only the agent directories you configure, checks the signature against keys from them, and returns the verified agent. It never authorizes: that stays your decision.
57
57
 
58
- Use `sign()` and `verify()` with explicit `Policy` values, caller-owned key
59
- resolvers, and replay storage. `Digest` handles RFC 9530 content digests;
60
- `DigestState` supports incremental hashing. See [signing and verification](docs/reference/sign-and-verify.md).
58
+ `content: "not_required"` skips body digest checks; `replay: "not_required"` allows repeated signatures within their freshness window. These local test programs use unsigned snapshots with `requireSignedDirectory: false`; production directories should keep the signed-directory default. See the [signed directory program](docs/guides/web-bot-auth.md).
61
59
 
62
- ## Web Bot Auth
60
+ Each verification excerpt below is at most fifteen lines and matches the code between the `----` markers in its complete runnable program. The documentation check compares the two byte-for-byte, so edits cannot silently drift. Expand the program to see key generation, the caller-owned offline directory snapshot, and signed and unsigned requests with asserted outcomes.
63
61
 
64
- Verify a protocol-00 request with a caller-trusted, fresh directory `keySet`
65
- and captured branded `message`. The application supplies `now` in Unix seconds.
62
+ ### Express
66
63
 
67
- ```ts
68
- import { WebBotAuth, ok, type AgentRequest } from "@request-seal/core";
64
+ This GET route accepts bodyless requests. `captureRequest` runs first so the exact request line, headers, and body are kept before any parser touches them. See [Node HTTP](docs/guides/node-http.md) for Fastify, proxies, and body parsers.
65
+
66
+ In your project, run `npm install express` and use `import express from "express";`. Replace the complete program's `createRequire` setup with that import; it loads the repository's pinned dependency only for these executable examples.
67
+
68
+ Verification excerpt (14 lines):
69
69
 
70
- const policy = WebBotAuth.Policy.create({
70
+ <!-- verification-excerpt: express -->
71
+ ```typescript
72
+ const policy = must(WebBotAuth.Policy.create({
71
73
  algorithms: ["ed25519"],
72
- agents: ({ location }: AgentRequest) => ok(location === keySet.origin ? keySet : null),
74
+ agents: ({ location }: AgentRequest) => ok(location === keys.origin ? keys : null), // Trust only the directory you configured.
73
75
  cache: null,
74
- freshness: { clock: () => now, maxAge: 60, skew: 5 },
76
+ freshness: { clock: now, maxAge: 60, skew: 5 },
75
77
  content: "not_required",
76
78
  replay: "not_required",
79
+ }));
80
+ const app = express();
81
+ app.get("/", async (req: any, res: any) => {
82
+ const captured = await captureRequest(req); // Before any body parser.
83
+ const verified = captured.ok ? await verifyWebBotAuth(req, policy) : captured;
84
+ res.status(verified.ok ? 200 : 401).end(verified.ok ? verified.value.verification.signatures.crawler!.principal.kind : "");
77
85
  });
78
- if (!policy.ok) throw new Error(policy.error.reason);
79
- const authenticated = await WebBotAuth.verify(message, policy.value);
80
86
  ```
81
87
 
82
- Supply your own signed directory snapshot or explicitly configured discovery
83
- cache. Add required replay storage for nonce uniqueness. See
84
- [Web Bot Auth](docs/reference/web-bot-auth.md) for signing, trust, held keys,
85
- nested signatures, result facts, and replay commitments.
88
+ <details>
89
+ <summary>Complete runnable Express program</summary>
90
+
91
+ ```ts
92
+ import assert from "node:assert/strict";
93
+ import { once } from "node:events";
94
+ import { generateKeyPairSync } from "node:crypto";
95
+ import { createServer } from "node:http";
96
+ import { createRequire } from "node:module";
97
+ import { Custody, Discovery, DiscoverySource, Message, PublicKey, WebBotAuth, ok, type AgentRequest, type Result } from "@request-seal/core";
98
+ import { captureRequest, verifyWebBotAuth } from "@request-seal/core/node-http";
99
+
100
+ const must = <T>(r: Result<T>): T => { assert.ok(r.ok, JSON.stringify(r)); return (r as { value: T }).value; };
101
+ // In your project: import express from "express". This repository loads the version its tests pin.
102
+ const express = createRequire(new URL("../../interop/adapters/package.json", import.meta.url))("express");
103
+ const now = () => Math.floor(Date.now() / 1000);
104
+
105
+ // Test-only unsigned local snapshot. In production, keep the signed-directory default.
106
+ const crawler = generateKeyPairSync("ed25519");
107
+ const jwk = crawler.publicKey.export({ format: "jwk" });
108
+ const source = must(DiscoverySource.create({ type: "directory", location: "https://crawler.example", requireSignedDirectory: false }));
109
+ const keys = must(await Discovery.resolver.fromSnapshot(source, new TextEncoder().encode(JSON.stringify({ keys: [jwk] })), now(),
110
+ [["content-type", "application/http-message-signatures-directory+json"]]));
111
+
112
+ // ---- Your server: verify Web Bot Auth requests (14 lines) ----
113
+ const policy = must(WebBotAuth.Policy.create({
114
+ algorithms: ["ed25519"],
115
+ agents: ({ location }: AgentRequest) => ok(location === keys.origin ? keys : null), // Trust only the directory you configured.
116
+ cache: null,
117
+ freshness: { clock: now, maxAge: 60, skew: 5 },
118
+ content: "not_required",
119
+ replay: "not_required",
120
+ }));
121
+ const app = express();
122
+ app.get("/", async (req: any, res: any) => {
123
+ const captured = await captureRequest(req); // Before any body parser.
124
+ const verified = captured.ok ? await verifyWebBotAuth(req, policy) : captured;
125
+ res.status(verified.ok ? 200 : 401).end(verified.ok ? verified.value.verification.signatures.crawler!.principal.kind : "");
126
+ });
127
+ // ---- End of the verification code ----
128
+
129
+ const server = createServer(app).listen(0, "127.0.0.1");
130
+ await once(server, "listening");
131
+ const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/`;
132
+ const signer = must(await Custody.local("ed25519", crawler.privateKey.export({ type: "pkcs8", format: "pem" }) as string));
133
+ const signed = must(await WebBotAuth.sign(must(await Message.request("GET", url, [], "")), {
134
+ label: "crawler", key: must(await PublicKey.import(jwk)), algorithm: "ed25519",
135
+ agent: { location: "https://crawler.example", type: "directory" }, created: now(), expires: now() + 60, nonce: null,
136
+ }, signer));
137
+ const headers = signed.fields.map((f) => [f.name, new TextDecoder().decode(f.value)] as [string, string]);
138
+ const accepted = await fetch(url, { headers });
139
+ assert.equal(accepted.status, 200);
140
+ assert.equal(await accepted.text(), "agent");
141
+ assert.equal((await fetch(url)).status, 401);
142
+ server.close();
143
+ server.closeAllConnections();
144
+ ```
86
145
 
87
- ## JOSE
146
+ </details>
88
147
 
89
- Use `JWS` for compact signing and verification, `JWE` for encryption and
90
- authenticated decryption, and `Nested` for one-level nesting. Supply explicit
91
- algorithm policies and caller-owned custody or `KeyManagement` capabilities.
92
- See [JOSE APIs](docs/reference/jose.md).
148
+ ### Hono on Deno
93
149
 
94
- ## HTTP adapters and SSH custody
150
+ The example runs under Deno and pins Hono from JSR at `jsr:@hono/hono@4.10.3`. Hono is your dependency, not RequestSeal's. Fetch Headers merges duplicate field occurrences; this excerpt cannot recover them. Use the Node adapter when separate occurrences matter. The program builds a `Message` from the incoming request using the origin your service owns, never the request's own `Host`, and passes it to `WebBotAuth.verify`.
95
151
 
96
- Use `@request-seal/core/node-http` for Node ingress and clients, `@request-seal/core/fetch`
97
- for Fetch signing and proven Deno verification, and
98
- `@request-seal/core/custody-ssh-agent` for caller-selected OpenSSH custody.
99
- See [adapter contracts and framework recipes](docs/reference/adapters.md).
152
+ Verification excerpt (15 lines):
100
153
 
101
- ```ts
102
- import {captureRequest, verifyRequest} from "@request-seal/core/node-http";
103
-
104
- async function authenticate(req, policy) {
105
- const captured = await captureRequest(req); // Before body parsers.
106
- if (!captured.ok) return captured;
107
- const verified = await verifyRequest(req, policy, {label: "sig"});
108
- if (!verified.ok) return verified;
109
- // Feed the parser these authenticated bytes; the original stream is consumed.
110
- req.body = JSON.parse(new TextDecoder("utf-8", {fatal: true}).decode(verified.value.bytes));
111
- return verified;
112
- }
154
+ <!-- verification-excerpt: hono -->
155
+ <!-- run: deno -->
156
+ ```typescript
157
+ let origin = ""; // The public origin you serve. Take it from your configuration, never from the request.
158
+ const policy = must(WebBotAuth.Policy.create({
159
+ algorithms: ["ed25519"], components: '("@method" "@path" "@query")',
160
+ agents: ({ location }: AgentRequest) => ok(location === keys.origin ? keys : null), // Trust only the directory you configured.
161
+ cache: null,
162
+ freshness: { clock: now, maxAge: 60, skew: 5 },
163
+ content: "not_required",
164
+ replay: "not_required",
165
+ }));
166
+ const app = new Hono();
167
+ app.get("/*", async (c) => {
168
+ const message = await Message.request("GET", origin + new URL(c.req.url).pathname + new URL(c.req.url).search, [...c.req.raw.headers.entries()], "");
169
+ const verified = message.ok ? await WebBotAuth.verify(message.value, policy) : message;
170
+ return verified.ok ? c.text(verified.value.signatures.crawler!.principal.kind) : c.text("denied", 401);
171
+ });
113
172
  ```
114
173
 
115
- For Web Bot Auth, supply a branded `WebBotAuth.Policy` with caller-configured
116
- agent trust and discovery. The result is also available through `verification(req)`:
174
+ <details>
175
+ <summary>Complete runnable Hono on Deno program</summary>
117
176
 
177
+ <!-- run: deno -->
118
178
  ```ts
119
- import {captureRequest, verifyWebBotAuth} from "@request-seal/core/node-http";
120
-
121
- async function authenticateBot(req, policy) {
122
- const captured = await captureRequest(req); // Before body parsers.
123
- if (!captured.ok) return captured;
124
- return verifyWebBotAuth(req, policy, {timeoutMs: 5000});
125
- }
179
+ import assert from "node:assert/strict";
180
+ import { generateKeyPairSync } from "node:crypto";
181
+ // Hono is your dependency, pinned here so the example runs as written.
182
+ import { Hono } from "jsr:@hono/hono@4.10.3";
183
+ import { Custody, Discovery, DiscoverySource, Message, PublicKey, WebBotAuth, ok, type AgentRequest, type Result } from "@request-seal/core";
184
+
185
+ const must = <T>(r: Result<T>): T => { assert.ok(r.ok, JSON.stringify(r)); return (r as { value: T }).value; };
186
+ const now = () => Math.floor(Date.now() / 1000);
187
+
188
+ // Test-only unsigned local snapshot. In production, keep the signed-directory default.
189
+ const crawler = generateKeyPairSync("ed25519");
190
+ const jwk = crawler.publicKey.export({ format: "jwk" });
191
+ const source = must(DiscoverySource.create({ type: "directory", location: "https://crawler.example", requireSignedDirectory: false }));
192
+ const keys = must(await Discovery.resolver.fromSnapshot(source, new TextEncoder().encode(JSON.stringify({ keys: [jwk] })), now(),
193
+ [["content-type", "application/http-message-signatures-directory+json"]]));
194
+
195
+ // ---- Your server: verify Web Bot Auth requests (15 lines) ----
196
+ let origin = ""; // The public origin you serve. Take it from your configuration, never from the request.
197
+ const policy = must(WebBotAuth.Policy.create({
198
+ algorithms: ["ed25519"], components: '("@method" "@path" "@query")',
199
+ agents: ({ location }: AgentRequest) => ok(location === keys.origin ? keys : null), // Trust only the directory you configured.
200
+ cache: null,
201
+ freshness: { clock: now, maxAge: 60, skew: 5 },
202
+ content: "not_required",
203
+ replay: "not_required",
204
+ }));
205
+ const app = new Hono();
206
+ app.get("/*", async (c) => {
207
+ const message = await Message.request("GET", origin + new URL(c.req.url).pathname + new URL(c.req.url).search, [...c.req.raw.headers.entries()], "");
208
+ const verified = message.ok ? await WebBotAuth.verify(message.value, policy) : message;
209
+ return verified.ok ? c.text(verified.value.signatures.crawler!.principal.kind) : c.text("denied", 401);
210
+ });
211
+ // ---- End of the verification code ----
212
+
213
+ const server = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen: ({ port }) => { origin = `http://127.0.0.1:${port}`; } }, app.fetch);
214
+ const signer = must(await Custody.local("ed25519", crawler.privateKey.export({ type: "pkcs8", format: "pem" }) as string));
215
+ const signed = must(await WebBotAuth.sign(must(await Message.request("GET", origin + "/page?x=1&x=2", [], "")), {
216
+ label: "crawler", components: '("@method" "@path" "@query")', key: must(await PublicKey.import(jwk)), algorithm: "ed25519",
217
+ agent: { location: "https://crawler.example", type: "directory" }, created: now(), expires: now() + 60, nonce: null,
218
+ }, signer));
219
+ const headers = signed.fields.map((f) => [f.name, new TextDecoder().decode(f.value)] as [string, string]);
220
+ assert.equal(await (await fetch(origin + "/page?x=1&x=2", { headers })).text(), "agent");
221
+ const changedQuery = await fetch(origin + "/page?x=1&x=3", { headers });
222
+ assert.equal(changedQuery.status, 401);
223
+ await changedQuery.body?.cancel();
224
+ const refused = await fetch(origin + "/");
225
+ assert.equal(refused.status, 401);
226
+ await refused.body?.cancel();
227
+ await server.shutdown();
126
228
  ```
127
229
 
128
- ```ts
129
- import {signedFetch} from "@request-seal/core/fetch";
230
+ </details>
130
231
 
131
- async function send(url, spec, signer) {
132
- return signedFetch(url, {method: "POST", body: "hello"}, {spec, signer});
133
- }
134
- ```
232
+ ## Policy recipes
233
+
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
+
236
+ ### Strict RFC 9421
237
+
238
+ Accepts one algorithm, requires exact coverage, recomputes the body digest, bounds the signature count, and lets each signature work once. `createMemoryStore` is per process; use the [PostgreSQL store](docs/guides/replay-protection.md#postgresql-store) for multi-instance deployments.
135
239
 
136
240
  ```ts
137
- import {createSshAgent} from "@request-seal/core/custody-ssh-agent";
241
+ import assert from "node:assert/strict";
242
+ import { generateKeyPairSync } from "node:crypto";
243
+ import { Custody, Message, Policy, PublicKey, createMemoryStore, ok, sign, verify, type KeyResolver, type ReplayFacts, type Result } from "@request-seal/core";
244
+
245
+ const must = <T>(r: Result<T>): T => { assert.ok(r.ok, JSON.stringify(r)); return (r as { value: T }).value; };
246
+ const pair = generateKeyPairSync("ed25519");
247
+ const trusted = must(await PublicKey.import(pair.publicKey.export({ format: "jwk" })));
248
+ const keyResolver: KeyResolver = ({ keyid }) => ok(keyid === "partner-1" ? { algorithm: "ed25519", key: trusted } : null); // Your configured keys only.
249
+ const sha256 = async (text: string) => new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
250
+
251
+ // Strict generic RFC 9421 policy: copy this and change the key resolver.
252
+ const components = '("@method" "@scheme" "@authority" "@path" "@query" "content-digest")';
253
+ const policy = must(Policy.create({
254
+ algorithms: ["ed25519"], // Only what your partners use.
255
+ components, // Every accepted signature must cover these.
256
+ extraComponents: "reject", // ...and nothing else, so coverage cannot drift.
257
+ maxSignatures: 4, // Bound the work an attacker can request.
258
+ keyResolver, // Maps keyid to a key you trust; ok(null) for strangers.
259
+ freshness: { clock: () => Math.floor(Date.now() / 1000), maxAge: 300, skew: 5, requireExpires: true },
260
+ content: { kind: "content", algorithms: ["sha-256"], section: "headers" }, // Recompute the body digest.
261
+ replay: { // Each signature works once.
262
+ identifier: "nonce", namespace: new TextEncoder().encode("partner-api"), timeoutMs: 1000,
263
+ store: must(createMemoryStore({ maxEntries: 100_000 })),
264
+ commitment: async (facts: ReplayFacts) => ok(await sha256(JSON.stringify([facts.keyid, facts.identifier]))),
265
+ },
266
+ }));
267
+
268
+ // A partner signs a request, and it verifies exactly once.
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/tickets?id=7", [], '{"priority":2}', { digest: ["sha-256"] }));
271
+ const signed = must(await sign(request, { label: "sig", components, algorithm: "ed25519", expiresIn: 60, keyid: "partner-1", digest: ["sha-256"] }, signer));
272
+ must(await verify(signed, policy, { label: "sig" }));
273
+ const again = await verify(signed, policy, { label: "sig" });
274
+ assert.ok(!again.ok && again.error.kind === "core" && again.error.reason === "replayed");
275
+ ```
276
+
277
+ ### Strict Web Bot Auth
278
+
279
+ Trusts one configured directory, refuses long-lived signatures and unrelated labels, and claims each nonce once. `createMemoryStore` is per process; use the [PostgreSQL store](docs/guides/replay-protection.md#postgresql-store) for multi-instance deployments.
138
280
 
139
- async function bindAgent(socketPath, publicKey) {
140
- return createSshAgent("ed25519", socketPath, publicKey);
141
- }
281
+ ```ts
282
+ import assert from "node:assert/strict";
283
+ import { generateKeyPairSync } from "node:crypto";
284
+ import { Custody, Discovery, DiscoverySource, Message, PublicKey, WebBotAuth, createMemoryStore, ok, type AgentRequest, type Result, type WebBotAuthReplayFacts } from "@request-seal/core";
285
+
286
+ const must = <T>(r: Result<T>): T => { assert.ok(r.ok, JSON.stringify(r)); return (r as { value: T }).value; };
287
+ const now = () => Math.floor(Date.now() / 1000);
288
+ const sha256 = async (text: string) => new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
289
+ const agent = generateKeyPairSync("ed25519");
290
+ const jwk = agent.publicKey.export({ format: "jwk" });
291
+ // Test-only unsigned snapshot; use the signed-directory default in production.
292
+ const source = must(DiscoverySource.create({ type: "directory", location: "https://agent.example", requireSignedDirectory: false }));
293
+ const keys = must(await Discovery.resolver.fromSnapshot(source, new TextEncoder().encode(JSON.stringify({ keys: [jwk] })), now(),
294
+ [["content-type", "application/http-message-signatures-directory+json"]]));
295
+
296
+ // Strict Web Bot Auth policy: copy this and change the trust callback.
297
+ const policy = must(WebBotAuth.Policy.create({
298
+ algorithms: ["ed25519"], // HMAC is never allowed for Web Bot Auth.
299
+ agents: ({ location }: AgentRequest) => ok(location === keys.origin ? keys : null), // Trust only directories you configured.
300
+ cache: null, // Or a createCache() capability when agents return sources.
301
+ freshness: { clock: now, maxAge: 60, skew: 5 }, // Expiry is always required.
302
+ maxLifetime: 300, // Refuse long-lived signatures (default 86,400 seconds).
303
+ untagged: "reject", // Refuse extra signatures that are not Web Bot Auth.
304
+ testKeys: "reject", // Refuse published example keys (this is the default).
305
+ content: "not_required",
306
+ replay: { // One use per nonce, across every selected signature.
307
+ identifier: "nonce", namespace: new TextEncoder().encode("crawlers"), timeoutMs: 1000,
308
+ store: must(createMemoryStore({ maxEntries: 100_000 })),
309
+ commitment: async (facts: WebBotAuthReplayFacts) => ok(await sha256(JSON.stringify([facts.profile, facts.signatures.map((s) => [s.keyid, s.identifier]).sort()]))),
310
+ },
311
+ }));
312
+
313
+ const signer = must(await Custody.local("ed25519", agent.privateKey.export({ type: "pkcs8", format: "pem" }) as string));
314
+ const request = must(await Message.request("GET", "https://site.example/page", [], ""));
315
+ const signed = must(await WebBotAuth.sign(request, {
316
+ label: "bot", key: must(await PublicKey.import(jwk)), algorithm: "ed25519",
317
+ agent: { location: "https://agent.example", type: "directory" }, created: now(), expires: now() + 60, nonce: crypto.randomUUID(),
318
+ }, signer));
319
+ assert.equal(must(await WebBotAuth.verify(signed, policy)).signatures.bot!.principal.kind, "agent");
320
+ const again = await WebBotAuth.verify(signed, policy);
321
+ assert.ok(!again.ok && again.error.kind === "core" && again.error.reason === "replayed");
142
322
  ```
143
323
 
144
- The caller supplies the socket and branded public key and owns agent startup
145
- and key loading. Every returned signature is verified locally.
324
+ ## Reason to HTTP status (recommendation)
146
325
 
147
- Fetch verification returns `invalid_request` outside the tested Deno 2.5.6
148
- allowlist. Exact-target, cookie, and per-occurrence byte coverage remain refused.
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.
149
327
 
150
- ## Compatibility
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.
151
329
 
152
- Compatibility is checked against the byte-preserved RequestSeal `v0.4.0` corpus. See [the corpus pin and update command](CONTRIBUTING.md). Development reports pending surfaces; releases require complete coverage.
330
+ | Status | Reasons |
331
+ | --- | --- |
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
+ | 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
+ | 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/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
+ | 503 | `replay/*`, `core/replay.store_failed`, `core/replay.store_timeout`, `core/replay.store_unavailable` |
337
+
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.
339
+
340
+ ## Standards
341
+
342
+ | Standard | What RequestSeal implements |
343
+ | --- | --- |
344
+ | [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html) HTTP Message Signatures | Signing and verification, derived components, multiple signatures, `Accept-Signature`, and the six algorithms in the [IANA registry](https://www.iana.org/assignments/http-message-signature) that the library supports. |
345
+ | [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html) Digest Fields | `Content-Digest` and `Repr-Digest` with SHA-256 and SHA-512, including streamed bodies. |
346
+ | [RFC 8941](https://www.rfc-editor.org/rfc/rfc8941.html) and [RFC 9651](https://www.rfc-editor.org/rfc/rfc9651.html) Structured Field Values | Parsing and serialization under explicit schemas. |
347
+ | [draft-ietf-webbotauth-httpsig-protocol-00](https://www.ietf.org/archive/id/draft-ietf-webbotauth-httpsig-protocol-00.html) | Web Bot Auth signing, verification, key directories, and nested signatures. A draft can change; pin your version. |
348
+ | [RFC 7515](https://www.rfc-editor.org/rfc/rfc7515.html), [RFC 7516](https://www.rfc-editor.org/rfc/rfc7516.html), [RFC 7518](https://www.rfc-editor.org/rfc/rfc7518.html) JOSE | Compact JWS and JWE, and one-level nesting. |
349
+ | [RFC 7517](https://www.rfc-editor.org/rfc/rfc7517.html), [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638.html), [RFC 8037](https://www.rfc-editor.org/rfc/rfc8037.html) | JWK parsing, thumbprints, and Ed25519 keys. |
350
+ | [RFC 9113](https://www.rfc-editor.org/rfc/rfc9113.html) HTTP/2 | Pseudo-header handling in the Node HTTP adapter. |
351
+
352
+ ## Integrations
353
+
354
+ | Integration | Entry point | Proved by |
355
+ | --- | --- | --- |
356
+ | Node `http`, `https`, and `http2` | `@request-seal/core/node-http` | Real servers and clients on loopback. |
357
+ | Express 5.2.1 | `captureRequest`, `verifyRequest` | The [Node HTTP guide](docs/guides/node-http.md) program, and the framework tests. |
358
+ | Fastify 5.12.5 | `capture`, `captureRequest` | The Node HTTP guide program, and the framework tests. |
359
+ | Fetch, signing on Node, Deno, and Bun; verification on Deno 2.5.6 | `@request-seal/core/fetch` | The [Fetch guide](docs/guides/fetch.md) programs, and wire tests on each runtime. |
360
+ | Hono on Deno | `WebBotAuth.verify` | The Hono program above. |
361
+ | PostgreSQL replay store, with `pg` 8.23.1 | `@request-seal/core/replay-postgres` | A real PostgreSQL 18 server in CI. |
362
+ | OpenSSH agent custody | `@request-seal/core/custody-ssh-agent` | A real `ssh-agent`, with signatures checked by OpenSSL. |
363
+ | RequestSeal for Elixir 0.4.1 | HTTP and JWS algorithms, JWE, Web Bot Auth directories, replay claims | A two-way exchange in CI. |
364
+ | `http-message-signatures` 1.0.6 | RFC 9421 wire format | The [migration guide](docs/guides/migrating-from-http-message-signatures.md) tests. |
365
+
366
+ ## Security
367
+
368
+ RequestSeal reports what was cryptographically established and nothing more: the verified bytes, the key that signed them, and the trusted source of that key. It does not authorize anything. Private keys stay behind custody handles, and no error, log view, or serialization carries key material, stack traces, or caller bytes. Inputs are bounded in size, depth, and time, every operation takes a deadline, and discovery and replay run only where you configure them. Report a vulnerability privately through [GitHub security advisories](https://github.com/baselabs/request-seal-typescript/security/advisories/new); see [SECURITY.md](SECURITY.md) and the [threat model](docs/design/threat-model.md).
369
+
370
+ ## Guides
371
+
372
+ - [Getting started](docs/guides/getting-started.md)
373
+ - [Signing and verifying](docs/guides/signing-and-verifying.md)
374
+ - [Fetch](docs/guides/fetch.md)
375
+ - [Node HTTP, Express, and Fastify](docs/guides/node-http.md)
376
+ - [Web Bot Auth](docs/guides/web-bot-auth.md)
377
+ - [Replay protection](docs/guides/replay-protection.md)
378
+ - [Key discovery](docs/guides/key-discovery.md)
379
+ - [Signed and encrypted tokens (JOSE)](docs/guides/jose.md)
380
+ - [Key custody](docs/guides/key-custody.md)
381
+ - [Rotating keys](docs/guides/key-rotation.md)
382
+ - [Testing](docs/guides/testing.md)
383
+ - [Migrating from http-message-signatures](docs/guides/migrating-from-http-message-signatures.md)
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`.
386
+
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), 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).
388
+
389
+ ## Compatibility
153
390
 
154
- Node 22.12.0 and 24.21.0 and Deno 2.5.6 verify the pinned published vectors.
155
- Bun 1.3.14 WebCrypto rejects two valid Wycheproof ECDSA P-256 signatures:
156
- items 261 (`s = 2^128`) and 262 (`s = n - 2^128`). The consumer policy in
157
- [`tools/conformance/runtime-limits.json`](tools/conformance/runtime-limits.json)
158
- applies only to that exact version and reports both as `runtime_limited`.
159
- Published corpus verdicts stay valid. A stale entry, different rejection, unlisted
160
- divergence, or divergence on another Bun version fails conformance. See the
161
- [consumer runtime limitation contract](docs/reference/corpus-format.md#consumer-runtime-limitations)
162
- and [test API compatibility](CONTRIBUTING.md#runtime-test-compatibility).
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.
163
392
 
164
- See [replay protection](docs/reference/replay.md) for the memory store and the
165
- optional `@request-seal/core/replay-postgres` query adapter.
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).