@request-seal/core 0.1.0 → 0.2.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 (119) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +341 -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 +16 -0
  10. package/dist/adapters/node-http/index.js +8 -4
  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 +61 -1
  16. package/dist/constants.js +61 -1
  17. package/dist/context.d.ts +4 -0
  18. package/dist/context.js +3 -0
  19. package/dist/crypto/algorithm.d.ts +5 -0
  20. package/dist/crypto/index.d.ts +12 -2
  21. package/dist/crypto/index.js +34 -11
  22. package/dist/crypto/options.d.ts +3 -0
  23. package/dist/crypto/options.js +15 -0
  24. package/dist/crypto/primitive.d.ts +7 -3
  25. package/dist/crypto/primitive.js +36 -10
  26. package/dist/custody/content_key.d.ts +4 -0
  27. package/dist/custody/content_key.js +4 -2
  28. package/dist/custody/index.d.ts +2 -0
  29. package/dist/custody/index.js +5 -1
  30. package/dist/custody/local.d.ts +17 -1
  31. package/dist/custody/local.js +10 -5
  32. package/dist/custody/ssh-agent/index.d.ts +2 -0
  33. package/dist/custody/ssh-agent/index.js +11 -181
  34. package/dist/custody/ssh-agent/port.d.ts +19 -0
  35. package/dist/custody/ssh-agent/port.js +183 -0
  36. package/dist/digest.d.ts +22 -0
  37. package/dist/digest.js +15 -0
  38. package/dist/discovery/body.d.ts +7 -0
  39. package/dist/discovery/body.js +3 -0
  40. package/dist/discovery/cache.d.ts +9 -0
  41. package/dist/discovery/cache.js +2 -0
  42. package/dist/discovery/headers.d.ts +1 -0
  43. package/dist/discovery/index.d.ts +5 -0
  44. package/dist/discovery/index.js +10 -2
  45. package/dist/discovery/node-connection.d.ts +1 -1
  46. package/dist/discovery/node-connection.js +2 -2
  47. package/dist/discovery/node-trust.d.ts +2 -0
  48. package/dist/discovery/node-trust.js +9 -0
  49. package/dist/discovery/node.d.ts +4 -0
  50. package/dist/discovery/node.js +29 -11
  51. package/dist/discovery/source.d.ts +7 -0
  52. package/dist/discovery/source.js +5 -0
  53. package/dist/discovery/support.js +2 -3
  54. package/dist/errors/parity.d.ts +16 -1
  55. package/dist/errors/parity.js +14 -2
  56. package/dist/errors/types.d.ts +13 -1
  57. package/dist/errors/types.js +2 -0
  58. package/dist/index.d.ts +1 -0
  59. package/dist/index.js +1 -0
  60. package/dist/jose/header.d.ts +1 -0
  61. package/dist/jose/jwe.d.ts +9 -0
  62. package/dist/jose/jwe.js +17 -3
  63. package/dist/jose/jws.d.ts +11 -0
  64. package/dist/jose/jws.js +14 -2
  65. package/dist/jose/key-management.d.ts +11 -0
  66. package/dist/jose/key-management.js +13 -2
  67. package/dist/jose/nested.d.ts +6 -0
  68. package/dist/jose/nested.js +9 -1
  69. package/dist/json.d.ts +7 -0
  70. package/dist/key_handle.d.ts +12 -3
  71. package/dist/key_handle.js +32 -25
  72. package/dist/key_identity.d.ts +7 -0
  73. package/dist/key_identity.js +10 -2
  74. package/dist/message/index.d.ts +24 -0
  75. package/dist/message/index.js +20 -0
  76. package/dist/numbers.d.ts +6 -0
  77. package/dist/numbers.js +5 -0
  78. package/dist/policy.d.ts +15 -0
  79. package/dist/policy.js +30 -21
  80. package/dist/profile.d.ts +36 -0
  81. package/dist/profile.js +215 -0
  82. package/dist/public_key.d.ts +11 -2
  83. package/dist/public_key.js +31 -8
  84. package/dist/quorum/verification.d.ts +2 -0
  85. package/dist/quorum/verification.js +1 -0
  86. package/dist/quorum.d.ts +9 -0
  87. package/dist/quorum.js +7 -1
  88. package/dist/replay/claim.d.ts +5 -0
  89. package/dist/replay/claim.js +9 -1
  90. package/dist/replay/index.d.ts +6 -0
  91. package/dist/replay/index.js +13 -1
  92. package/dist/replay/postgres.d.ts +1 -0
  93. package/dist/replay/postgres.js +1 -0
  94. package/dist/replay/receipt.d.ts +1 -0
  95. package/dist/replay/receipt.js +1 -0
  96. package/dist/replay/store.d.ts +1 -0
  97. package/dist/replay/store.js +1 -0
  98. package/dist/replay-port.d.ts +6 -1
  99. package/dist/result.d.ts +3 -0
  100. package/dist/result.js +2 -0
  101. package/dist/sign.d.ts +5 -0
  102. package/dist/sign.js +1 -0
  103. package/dist/signature-base/index.d.ts +2 -0
  104. package/dist/signature-base/index.js +5 -1
  105. package/dist/structured-fields/index.d.ts +21 -0
  106. package/dist/structured-fields/index.js +14 -1
  107. package/dist/verification.d.ts +5 -1
  108. package/dist/verification.js +9 -6
  109. package/dist/verify-quorum.d.ts +1 -0
  110. package/dist/verify-quorum.js +1 -0
  111. package/dist/verify.d.ts +5 -0
  112. package/dist/verify.js +16 -11
  113. package/dist/web-bot-auth/index.d.ts +3 -0
  114. package/dist/web-bot-auth/index.js +3 -0
  115. package/dist/web-bot-auth/policy.d.ts +8 -0
  116. package/dist/web-bot-auth/policy.js +8 -4
  117. package/dist/web-bot-auth/verification.d.ts +8 -0
  118. package/dist/web-bot-auth/verification.js +4 -2
  119. package/package.json +17 -6
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.0 - October 11, 2026
6
+ - 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
+ - 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.
8
+ - 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.
9
+ - 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.
10
+ - Return bounded `discovery/transport_unavailable` when system-root access is denied. Refuse ineligible Fetch verified delivery before consuming request bytes.
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+
18
+ ## 0.1.0 - October 10, 2026
19
+ - 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
20
  - 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
21
  - 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
22
  - Add captured Node HTTP Web Bot Auth verification, shared attempt storage, and executable signed TLS directory examples.
package/README.md CHANGED
@@ -1,165 +1,392 @@
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 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
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}/orders`, { 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/orders", [], "")), { 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. Nothing is defaulted. 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/orders?id=7", [], '{"qty":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), 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/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` |
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
+ - [Testing](docs/guides/testing.md)
382
+ - [Migrating from http-message-signatures](docs/guides/migrating-from-http-message-signatures.md)
383
+
384
+ 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
+ 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
+
388
+ ## Compatibility
153
389
 
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).
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.
163
391
 
164
- See [replay protection](docs/reference/replay.md) for the memory store and the
165
- optional `@request-seal/core/replay-postgres` query adapter.
392
+ 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).
@@ -1,26 +1,37 @@
1
1
  import { type InnerList } from "./structured-fields/index.ts";
2
2
  import { type Message as HttpMessage } from "./message/index.ts";
3
3
  import { type Result } from "./result.ts";
4
+ /** A requested signature label, component list, and negotiation parameters. */
4
5
  export type SignatureRequest = Readonly<{
5
6
  label: string;
6
7
  components: InnerList;
7
8
  parameters: Readonly<Record<string, string | true>>;
8
9
  }>;
10
+ /** A branded ordered collection of signature negotiation requests. */
9
11
  export type AcceptSignature = readonly SignatureRequest[];
12
+ /** Check the private brand of parsed signature negotiation requests. */
10
13
  export declare function isAcceptSignature(v: unknown): v is AcceptSignature;
11
14
  export declare function applicable(requests: AcceptSignature, target: "request" | "response"): Result<AcceptSignature>;
15
+ /** Parse and validate Accept-Signature fields into negotiation requests. */
12
16
  export declare function parseAcceptSignature(wire: unknown, options: unknown): Result<AcceptSignature>;
17
+ /** Serialize branded negotiation requests as an Accept-Signature dictionary. */
13
18
  export declare function serializeAcceptSignature(requests: unknown): Result<Uint8Array>;
19
+ /** A chooser's selected algorithm and optional creation and expiration times. */
14
20
  export type SignatureChoice = Readonly<{
15
21
  algorithm: string;
16
22
  created: number | null;
17
23
  expires: number | null;
18
24
  }>;
25
+ /** Select signing parameters for a signature negotiation request. */
19
26
  export type SignatureChooser = (request: SignatureRequest) => Result<SignatureChoice> | Promise<Result<SignatureChoice>>;
20
27
  /** Fulfill atomically through caller-owned selection and the single-signature API. */
21
28
  export declare function fulfillAcceptSignature(message: unknown, requests: unknown, chooser: unknown, signer: unknown, options?: unknown): Promise<Result<HttpMessage>>;
29
+ /** Signature negotiation parsing, serialization, and fulfillment. */
22
30
  export declare const AcceptSignature: Readonly<{
31
+ /** Parse and validate the supported wire representation. */
23
32
  parse: typeof parseAcceptSignature;
33
+ /** Serialize validated values to their canonical wire representation. */
24
34
  serialize: typeof serializeAcceptSignature;
35
+ /** Select and add applicable requested signatures with caller-owned signing custody. */
25
36
  fulfill: typeof fulfillAcceptSignature;
26
37
  }>;