@tollstile/web-bot-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +695 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paradigm AI Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @tollstile/web-bot-auth
|
|
2
|
+
|
|
3
|
+
`verifiedAgent()` is a Tollstile requirement that admits only requests signed by an agent you trust, using [Web Bot Auth](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/): [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) HTTP message signatures whose keys are published at the agent's `/.well-known/http-message-signatures-directory`.
|
|
4
|
+
|
|
5
|
+
It answers *who* is calling, not whether they paid. Put it in `require` next to any rail or access policy; it runs after the payer is known and before anything is reserved, so a denied request never touches the ledger.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install tollstile @tollstile/web-bot-auth
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Example
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createTollstile, memoryLedger, testRail } from 'tollstile';
|
|
17
|
+
import { paid } from '@tollstile/fetch';
|
|
18
|
+
import { verifiedAgent } from '@tollstile/web-bot-auth';
|
|
19
|
+
|
|
20
|
+
const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() });
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
fetch: paid(
|
|
24
|
+
toll.price('$0.01', { require: [verifiedAgent({ trust: ['https://agent.example'] })] }),
|
|
25
|
+
() => Response.json({ forecast: 'clear' }),
|
|
26
|
+
),
|
|
27
|
+
};
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A request is admitted when it carries a valid payment **and** a signature like:
|
|
31
|
+
|
|
32
|
+
```http
|
|
33
|
+
Signature-Agent: sig1="https://agent.example"
|
|
34
|
+
Signature-Input: sig1=("@authority" "@method" "@path" "signature-agent";key="sig1");created=1767225600;expires=1767225660;keyid="<JWK thumbprint>";alg="ed25519";nonce="…";tag="web-bot-auth"
|
|
35
|
+
Signature: sig1=:…:
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Otherwise the request is denied before anything is reserved. A refused signature is `403`:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"error": { "code": "requirement_failed", "retryable": false, "action": "stop", "message": "…", "detail": "signature_invalid" },
|
|
43
|
+
"requirement": "verified-agent",
|
|
44
|
+
"resource": "GET /data"
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
When the agent's key directory cannot be reached right now, the response is `503` with a `Retry-After` header and `"code": "requirement_unavailable"`, `"action": "retry_later"`, `"detail": "directory_unavailable"`.
|
|
49
|
+
|
|
50
|
+
Branch on `error.code` and `error.action`. `error.detail` is one of the stable snake_case identifiers below, suitable for logs and metrics.
|
|
51
|
+
|
|
52
|
+
## Options
|
|
53
|
+
|
|
54
|
+
| Option | Default | Description |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `trust` | required | Signature-Agent origins you accept (`["https://agent.example"]`), or `(origin) => boolean`. Only these origins' directories are ever fetched. A predicate is your SSRF boundary: the library cannot block private address ranges with Web-standard `fetch`. |
|
|
57
|
+
| `maxAgeMs` | `300000` | Oldest `created` accepted. |
|
|
58
|
+
| `clockSkewMs` | `5000` | Tolerance applied to `created`, `expires`, and the maximum age. |
|
|
59
|
+
| `requireNonce` | `false` | Reject signatures without a `nonce`. Nonces that are present are always single-use. |
|
|
60
|
+
| `cacheTtlMs` | `3600000` | Longest a directory is reused. A shorter `Cache-Control: max-age` wins, but never below one minute. |
|
|
61
|
+
| `timeoutMs` | `3000` | Upper bound for one directory fetch, including the body. The fetch is also aborted when Tollstile's `providerTimeoutMs` elapses (`input.signal`). |
|
|
62
|
+
| `fetch` | global `fetch` | Inject for tests or egress proxies. |
|
|
63
|
+
|
|
64
|
+
## What is checked
|
|
65
|
+
|
|
66
|
+
In this order, for each signature whose `tag` is `web-bot-auth` (the first that verifies wins):
|
|
67
|
+
|
|
68
|
+
1. `Signature-Input` and `Signature` parse as RFC 8941 Dictionaries; only the parameters RFC 9421 defines are allowed.
|
|
69
|
+
2. `created`, `expires`, and `keyid` are present; `@authority` or `@target-uri` is covered without parameters.
|
|
70
|
+
3. `created` is not in the future, `expires` has not passed, and `created` is within `maxAgeMs` (each ± `clockSkewMs`).
|
|
71
|
+
4. The covered Signature-Agent member: exactly one `"signature-agent";key="<member>"` for the Dictionary form, or `"signature-agent"` for the legacy sf-string form. The member must be an HTTPS origin; `type=jwks_uri` and `type=cimd` members are not resolved.
|
|
72
|
+
5. The signature base is rebuilt from `context.request` only — method, URL, and headers as the server received them. A missing, duplicated, or unsupported component fails.
|
|
73
|
+
6. The origin is trusted, its directory resolves, and it contains a key whose RFC 7638 thumbprint equals `keyid` (a `kid` that is not the thumbprint disqualifies the entry). RFC 9421's published test keys are refused.
|
|
74
|
+
7. The algorithm follows from the key (`ed25519`, `ecdsa-p256-sha256`, `ecdsa-p384-sha384`, `rsa-pss-sha512`); a signature `alg` must agree. RSA keys must name `PS512`/`rsa-pss-sha512` in the key or the signature.
|
|
75
|
+
8. A `nonce`, if present, is claimed once per directory and key until the signature could no longer be accepted.
|
|
76
|
+
|
|
77
|
+
| `error.detail` | Meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `http_request_required` | MCP call, or no HTTP request in the context. MCP tool calls are never attributed to a signed HTTP request. |
|
|
80
|
+
| `signature_missing` / `signature_malformed` / `tag_missing` | No usable Web Bot Auth signature. |
|
|
81
|
+
| `signature_parameters_missing` / `target_not_covered` | Profile requirements not met. |
|
|
82
|
+
| `signature_not_yet_valid` / `signature_expired` / `signature_too_old` | Freshness. |
|
|
83
|
+
| `signature_agent_missing` / `signature_agent_not_covered` / `signature_agent_malformed` / `signature_agent_unsupported` | The Signature-Agent member cannot be attributed. |
|
|
84
|
+
| `signature_base_invalid` | A covered component cannot be derived from the request. |
|
|
85
|
+
| `agent_untrusted` | The origin is not in `trust`. Nothing was fetched. |
|
|
86
|
+
| `directory_unavailable` (**503**) | The directory could not be reached — network or TLS error, timeout (`timeoutMs` or the provider timeout), or a 5xx — and nothing usable is cached. The request is unverified, not forbidden; retry later. |
|
|
87
|
+
| `directory_invalid` | The directory answered, but not with a usable directory: a redirect, another non-200 status, over 64 KiB, more than 32 keys, or malformed JSON. The operator has to fix it. |
|
|
88
|
+
| `key_not_found` / `algorithm_mismatch` / `signature_invalid` | Key selection or cryptographic verification failed. |
|
|
89
|
+
| `nonce_missing` / `nonce_replayed` | Replay protection. |
|
|
90
|
+
|
|
91
|
+
## Directory discovery
|
|
92
|
+
|
|
93
|
+
- `GET https://<origin>/.well-known/http-message-signatures-directory` with `redirect: "manual"`; only `200` is accepted. Unreachable directories and 5xx are `directory_unavailable` (503); redirects, other statuses, bodies over 64 KiB, more than 32 keys, or malformed JSON are `directory_invalid` (403). Unsupported or malformed key entries are skipped.
|
|
94
|
+
- Unavailability is returned as `{ ok: false, status: 503 }` rather than thrown as `PROVIDER_UNAVAILABLE`, so `error.detail` stays `directory_unavailable`; either way nothing is reserved.
|
|
95
|
+
- Concurrent requests for the same origin share one fetch, bounded by the signal of the request that started it. At most 1,024 directories are cached.
|
|
96
|
+
- A directory that resolves replaces the cached one, so a removed key stops verifying. A failed fetch of either kind is not evidence: the cached directory keeps verifying for up to 24 hours past its expiry, and the origin is not retried for 30 seconds.
|
|
97
|
+
- The cache and in-flight fetches are per `verifiedAgent()` instance and per process.
|
|
98
|
+
|
|
99
|
+
## Deployment notes
|
|
100
|
+
|
|
101
|
+
- **Reconstruct the public URL.** `@authority` and `@target-uri` come from `request.url`. Behind a proxy, the adapter must build the URL the agent signed (e.g. Express `trust proxy`), or every signature fails.
|
|
102
|
+
- **Cover more than `@authority`.** A signature over `@authority` alone can be replayed against any path until it expires. Ask agents to cover `@method` and `@path`, or require nonces.
|
|
103
|
+
- **Nonce claims and retries.** A nonce is claimed before the charge is created, so a request whose handler fails cannot be retried with the same signature; agents sign each attempt.
|
|
104
|
+
|
|
105
|
+
## Verification status
|
|
106
|
+
|
|
107
|
+
Tested with Vitest (Node 22 WebCrypto) against:
|
|
108
|
+
|
|
109
|
+
- RFC 9421 Appendix B.2.6 (ed25519), B.2.2 and B.2.3 (rsa-pss-sha512, including `@query-param` and `@query`), and the §2.2.8 query encoding examples — signature bases rebuilt byte for byte and signatures verified.
|
|
110
|
+
- draft-ietf-webbotauth-httpsig-protocol-00 Appendix E.1.1, E.1.2, E.2.1, and E.2.2 (Dictionary and legacy Signature-Agent, ed25519 and rsa-pss-sha512).
|
|
111
|
+
- Full requirement flows through `createTollstile` with `testRail()`, fake directories via injected `fetch`, and freshly generated ed25519, P-256, and RSA-PSS keys: untrusted origins never fetched, redirects and non-200 refused, size and key-count caps, 503 for unreachable, 5xx, `timeoutMs`, and provider-timeout (`input.signal`) failures, stale-on-failure caching and its 24-hour limit, `max-age` handling, fetch coalescing, nonce replay, and MCP fail-closed.
|
|
112
|
+
|
|
113
|
+
Not verified against a live agent. To verify: sign a request with Cloudflare's reference signer ([`web-bot-auth`](https://github.com/cloudflare/web-bot-auth), `sign()` with `signatureAgentKey`), host its JWKS at `https://<your-agent>/.well-known/http-message-signatures-directory`, trust that origin, and call your endpoint — expect `200`; change one covered header and expect `403 signature_invalid`.
|
|
114
|
+
|
|
115
|
+
## Not implemented
|
|
116
|
+
|
|
117
|
+
- **Visa Trusted Agent Protocol.** TAP reuses RFC 9421 with tags `agent-browser-auth`/`agent-payer-auth`, `@authority` + `@path`, mandatory nonces, and an 8-minute window, but keys come from Visa's JWKS selected by `kid` rather than a Signature-Agent thumbprint, algorithm names are JOSE names or the unregistered `rsa-pss-sha256`, and Visa's reference agent emits `Signature-Input` parameters that are not valid RFC 8941 (`keyId`, spaces). A strict verifier would reject its own samples, so a `tap` option is future work pending a conformant signer and published test vectors.
|
|
118
|
+
- `jwks_uri` and `cimd` Signature-Agent types, directory response signatures (Appendix B), and requests without Signature-Agent (thumbprint-only identity).
|
|
119
|
+
- Exposing the verified agent identity to the handler: requirements return only pass or fail.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Requirement } from 'tollstile';
|
|
2
|
+
|
|
3
|
+
type VerifiedAgentOptions = {
|
|
4
|
+
/**
|
|
5
|
+
* Signature-Agent origins you accept, e.g. `["https://agent.example"]`, or a predicate over the
|
|
6
|
+
* origin. Directories of other origins are never fetched.
|
|
7
|
+
*/
|
|
8
|
+
readonly trust: readonly string[] | ((origin: string) => boolean);
|
|
9
|
+
/** Oldest `created` accepted, in milliseconds. Defaults to 5 minutes. */
|
|
10
|
+
readonly maxAgeMs?: number;
|
|
11
|
+
/** Tolerated clock difference with signers, in milliseconds. Defaults to 5 seconds. */
|
|
12
|
+
readonly clockSkewMs?: number;
|
|
13
|
+
/** Reject signatures without a `nonce`. Nonces that are present are always single-use. Defaults to `false`. */
|
|
14
|
+
readonly requireNonce?: boolean;
|
|
15
|
+
/** Longest a fetched directory is reused, in milliseconds; a shorter `Cache-Control: max-age` wins. Defaults to 1 hour. */
|
|
16
|
+
readonly cacheTtlMs?: number;
|
|
17
|
+
/** Upper bound for one directory fetch, in milliseconds, within Tollstile's provider timeout. Defaults to 3 seconds. */
|
|
18
|
+
readonly timeoutMs?: number;
|
|
19
|
+
/** Defaults to the global `fetch`. */
|
|
20
|
+
readonly fetch?: typeof fetch;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Admits only requests signed by a trusted agent under Web Bot Auth (RFC 9421 HTTP message
|
|
24
|
+
* signatures, draft-ietf-webbotauth-httpsig-protocol-00). Keys come from the agent's
|
|
25
|
+
* `/.well-known/http-message-signatures-directory`. Denies with 403 and a reason such as
|
|
26
|
+
* `signature_missing`, `signature_invalid`, `agent_untrusted`, or `key_not_found`, and with 503
|
|
27
|
+
* `directory_unavailable` when the agent's directory cannot be reached and nothing usable is cached.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { verifiedAgent } from "@tollstile/web-bot-auth";
|
|
32
|
+
*
|
|
33
|
+
* app.get("/data", tollstile(toll.price("$0.01", {
|
|
34
|
+
* require: [verifiedAgent({ trust: ["https://agent.example"] })],
|
|
35
|
+
* })), handler);
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
declare function verifiedAgent(options: VerifiedAgentOptions): Requirement;
|
|
39
|
+
|
|
40
|
+
export { type VerifiedAgentOptions, verifiedAgent };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
// src/verified-agent.ts
|
|
2
|
+
import { TollstileError } from "tollstile";
|
|
3
|
+
|
|
4
|
+
// src/base64.ts
|
|
5
|
+
var BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
6
|
+
var BASE64URL = /^[A-Za-z0-9_-]*$/;
|
|
7
|
+
function decodeBase64(input) {
|
|
8
|
+
if (!BASE64.test(input)) return void 0;
|
|
9
|
+
const unpadded = input.replace(/=+$/, "");
|
|
10
|
+
if (unpadded.length % 4 === 1) return void 0;
|
|
11
|
+
return toBytes(atob(unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, "=")));
|
|
12
|
+
}
|
|
13
|
+
function encodeBase64(bytes) {
|
|
14
|
+
let binary = "";
|
|
15
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
16
|
+
return btoa(binary);
|
|
17
|
+
}
|
|
18
|
+
function decodeBase64url(input) {
|
|
19
|
+
if (!BASE64URL.test(input) || input.length % 4 === 1) return void 0;
|
|
20
|
+
return decodeBase64(input.replace(/-/g, "+").replace(/_/g, "/"));
|
|
21
|
+
}
|
|
22
|
+
function encodeBase64url(bytes) {
|
|
23
|
+
return encodeBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
24
|
+
}
|
|
25
|
+
function toBytes(binary) {
|
|
26
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/structured-fields.ts
|
|
30
|
+
var KEY_START = /[a-z*]/;
|
|
31
|
+
var KEY_CHAR = /[a-z0-9_\-.*]/;
|
|
32
|
+
var TOKEN_START = /[A-Za-z*]/;
|
|
33
|
+
var TOKEN_CHAR = /[!#$%&'*+\-.^_`|~0-9A-Za-z:/]/;
|
|
34
|
+
var DIGIT = /[0-9]/;
|
|
35
|
+
function parseDictionary(text) {
|
|
36
|
+
const cursor = { text, position: 0 };
|
|
37
|
+
skipSpaces(cursor);
|
|
38
|
+
const dictionary = /* @__PURE__ */ new Map();
|
|
39
|
+
while (cursor.position < text.length) {
|
|
40
|
+
const key = parseKey(cursor);
|
|
41
|
+
if (key === void 0) return void 0;
|
|
42
|
+
let member;
|
|
43
|
+
if (peek(cursor) === "=") {
|
|
44
|
+
cursor.position += 1;
|
|
45
|
+
member = parseMember(cursor);
|
|
46
|
+
} else {
|
|
47
|
+
const parameters = parseParameters(cursor);
|
|
48
|
+
member = parameters && { kind: "item", value: { type: "boolean", value: true }, parameters };
|
|
49
|
+
}
|
|
50
|
+
if (member === void 0) return void 0;
|
|
51
|
+
dictionary.set(key, member);
|
|
52
|
+
skipWhitespace(cursor);
|
|
53
|
+
if (cursor.position === text.length) break;
|
|
54
|
+
if (peek(cursor) !== ",") return void 0;
|
|
55
|
+
cursor.position += 1;
|
|
56
|
+
skipWhitespace(cursor);
|
|
57
|
+
if (cursor.position === text.length) return void 0;
|
|
58
|
+
}
|
|
59
|
+
return dictionary;
|
|
60
|
+
}
|
|
61
|
+
function parseItem(text) {
|
|
62
|
+
const cursor = { text, position: 0 };
|
|
63
|
+
skipSpaces(cursor);
|
|
64
|
+
const item = parseParameterizedItem(cursor);
|
|
65
|
+
skipSpaces(cursor);
|
|
66
|
+
return cursor.position === text.length ? item : void 0;
|
|
67
|
+
}
|
|
68
|
+
function serializeMember(member) {
|
|
69
|
+
if (member.kind === "item") return serializeBareItem(member.value) + serializeParameters(member.parameters);
|
|
70
|
+
return `(${member.items.map(serializeMember).join(" ")})${serializeParameters(member.parameters)}`;
|
|
71
|
+
}
|
|
72
|
+
function parseMember(cursor) {
|
|
73
|
+
if (peek(cursor) !== "(") return parseParameterizedItem(cursor);
|
|
74
|
+
cursor.position += 1;
|
|
75
|
+
const items = [];
|
|
76
|
+
for (; ; ) {
|
|
77
|
+
skipSpaces(cursor);
|
|
78
|
+
if (peek(cursor) === ")") {
|
|
79
|
+
cursor.position += 1;
|
|
80
|
+
const parameters = parseParameters(cursor);
|
|
81
|
+
return parameters && { kind: "inner-list", items, parameters };
|
|
82
|
+
}
|
|
83
|
+
const item = parseParameterizedItem(cursor);
|
|
84
|
+
if (item === void 0) return void 0;
|
|
85
|
+
items.push(item);
|
|
86
|
+
const next = peek(cursor);
|
|
87
|
+
if (next !== " " && next !== ")") return void 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function parseParameterizedItem(cursor) {
|
|
91
|
+
const value = parseBareItem(cursor);
|
|
92
|
+
const parameters = value && parseParameters(cursor);
|
|
93
|
+
return value && parameters && { kind: "item", value, parameters };
|
|
94
|
+
}
|
|
95
|
+
function parseParameters(cursor) {
|
|
96
|
+
const parameters = /* @__PURE__ */ new Map();
|
|
97
|
+
while (peek(cursor) === ";") {
|
|
98
|
+
cursor.position += 1;
|
|
99
|
+
skipSpaces(cursor);
|
|
100
|
+
const key = parseKey(cursor);
|
|
101
|
+
if (key === void 0) return void 0;
|
|
102
|
+
let value = { type: "boolean", value: true };
|
|
103
|
+
if (peek(cursor) === "=") {
|
|
104
|
+
cursor.position += 1;
|
|
105
|
+
value = parseBareItem(cursor);
|
|
106
|
+
}
|
|
107
|
+
if (value === void 0) return void 0;
|
|
108
|
+
parameters.set(key, value);
|
|
109
|
+
}
|
|
110
|
+
return parameters;
|
|
111
|
+
}
|
|
112
|
+
function parseKey(cursor) {
|
|
113
|
+
if (!KEY_START.test(peek(cursor))) return void 0;
|
|
114
|
+
const start = cursor.position;
|
|
115
|
+
while (KEY_CHAR.test(peek(cursor))) cursor.position += 1;
|
|
116
|
+
return cursor.text.slice(start, cursor.position);
|
|
117
|
+
}
|
|
118
|
+
function parseBareItem(cursor) {
|
|
119
|
+
const first = peek(cursor);
|
|
120
|
+
if (first === "-" || DIGIT.test(first)) return parseNumber(cursor);
|
|
121
|
+
if (first === '"') return parseString(cursor);
|
|
122
|
+
if (first === ":") return parseBytes(cursor);
|
|
123
|
+
if (first === "?") return parseBoolean(cursor);
|
|
124
|
+
if (TOKEN_START.test(first)) return parseToken(cursor);
|
|
125
|
+
return void 0;
|
|
126
|
+
}
|
|
127
|
+
function parseNumber(cursor) {
|
|
128
|
+
const match = /^(-?)(\d{1,15})(?:\.(\d{1,3}))?/.exec(cursor.text.slice(cursor.position));
|
|
129
|
+
if (match === null) return void 0;
|
|
130
|
+
const [whole, , integer = "", fraction] = match;
|
|
131
|
+
if (fraction !== void 0 && integer.length > 12) return void 0;
|
|
132
|
+
const next = cursor.text[cursor.position + whole.length];
|
|
133
|
+
if (next !== void 0 && (DIGIT.test(next) || fraction === void 0 && next === ".")) return void 0;
|
|
134
|
+
cursor.position += whole.length;
|
|
135
|
+
return fraction === void 0 ? { type: "integer", value: Number(whole) } : { type: "decimal", value: Number(whole) };
|
|
136
|
+
}
|
|
137
|
+
function parseString(cursor) {
|
|
138
|
+
cursor.position += 1;
|
|
139
|
+
let value = "";
|
|
140
|
+
while (cursor.position < cursor.text.length) {
|
|
141
|
+
const character = cursor.text.charAt(cursor.position);
|
|
142
|
+
cursor.position += 1;
|
|
143
|
+
if (character === '"') return { type: "string", value };
|
|
144
|
+
if (character === "\\") {
|
|
145
|
+
const escaped = cursor.text.charAt(cursor.position);
|
|
146
|
+
if (escaped !== '"' && escaped !== "\\") return void 0;
|
|
147
|
+
value += escaped;
|
|
148
|
+
cursor.position += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const code = character.charCodeAt(0);
|
|
152
|
+
if (code < 32 || code > 126) return void 0;
|
|
153
|
+
value += character;
|
|
154
|
+
}
|
|
155
|
+
return void 0;
|
|
156
|
+
}
|
|
157
|
+
function parseToken(cursor) {
|
|
158
|
+
const start = cursor.position;
|
|
159
|
+
cursor.position += 1;
|
|
160
|
+
while (TOKEN_CHAR.test(peek(cursor))) cursor.position += 1;
|
|
161
|
+
return { type: "token", value: cursor.text.slice(start, cursor.position) };
|
|
162
|
+
}
|
|
163
|
+
function parseBytes(cursor) {
|
|
164
|
+
const end = cursor.text.indexOf(":", cursor.position + 1);
|
|
165
|
+
if (end === -1) return void 0;
|
|
166
|
+
const value = decodeBase64(cursor.text.slice(cursor.position + 1, end));
|
|
167
|
+
cursor.position = end + 1;
|
|
168
|
+
return value && { type: "bytes", value };
|
|
169
|
+
}
|
|
170
|
+
function parseBoolean(cursor) {
|
|
171
|
+
const digit = cursor.text.charAt(cursor.position + 1);
|
|
172
|
+
if (digit !== "0" && digit !== "1") return void 0;
|
|
173
|
+
cursor.position += 2;
|
|
174
|
+
return { type: "boolean", value: digit === "1" };
|
|
175
|
+
}
|
|
176
|
+
function serializeBareItem(item) {
|
|
177
|
+
switch (item.type) {
|
|
178
|
+
case "integer":
|
|
179
|
+
return item.value.toString();
|
|
180
|
+
case "decimal": {
|
|
181
|
+
const rounded = Math.round(item.value * 1e3) / 1e3;
|
|
182
|
+
return Number.isInteger(rounded) ? rounded.toFixed(1) : rounded.toString();
|
|
183
|
+
}
|
|
184
|
+
case "string":
|
|
185
|
+
return `"${item.value.replace(/[\\"]/g, (character) => `\\${character}`)}"`;
|
|
186
|
+
case "token":
|
|
187
|
+
return item.value;
|
|
188
|
+
case "bytes":
|
|
189
|
+
return `:${encodeBase64(item.value)}:`;
|
|
190
|
+
case "boolean":
|
|
191
|
+
return item.value ? "?1" : "?0";
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function serializeParameters(parameters) {
|
|
195
|
+
let serialized = "";
|
|
196
|
+
for (const [key, value] of parameters) {
|
|
197
|
+
serialized += value.type === "boolean" && value.value ? `;${key}` : `;${key}=${serializeBareItem(value)}`;
|
|
198
|
+
}
|
|
199
|
+
return serialized;
|
|
200
|
+
}
|
|
201
|
+
function peek(cursor) {
|
|
202
|
+
return cursor.text.charAt(cursor.position);
|
|
203
|
+
}
|
|
204
|
+
function skipSpaces(cursor) {
|
|
205
|
+
while (peek(cursor) === " ") cursor.position += 1;
|
|
206
|
+
}
|
|
207
|
+
function skipWhitespace(cursor) {
|
|
208
|
+
while (peek(cursor) === " " || peek(cursor) === " ") cursor.position += 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/http-signature.ts
|
|
212
|
+
var INTEGER_PARAMETERS = /* @__PURE__ */ new Set(["created", "expires"]);
|
|
213
|
+
var STRING_PARAMETERS = /* @__PURE__ */ new Set(["nonce", "alg", "keyid", "tag"]);
|
|
214
|
+
var FIELD_NAME = /^[!#$%&'*+\-.^_`|~0-9a-z]+$/;
|
|
215
|
+
var SIGNATURE_BASE = /^[\t\n\x20-\x7e]*$/;
|
|
216
|
+
function readSignatures(headers) {
|
|
217
|
+
const inputField = headers.get("signature-input");
|
|
218
|
+
const signatureField = headers.get("signature");
|
|
219
|
+
if (inputField === null || signatureField === null) return "signature_missing";
|
|
220
|
+
const inputs = parseDictionary(inputField);
|
|
221
|
+
const signatures = parseDictionary(signatureField);
|
|
222
|
+
if (inputs === void 0 || signatures === void 0) return "signature_malformed";
|
|
223
|
+
const result = [];
|
|
224
|
+
for (const [label, member] of inputs) {
|
|
225
|
+
const signature = signatures.get(label);
|
|
226
|
+
if (member.kind !== "inner-list" || signature?.kind !== "item" || signature.value.type !== "bytes") continue;
|
|
227
|
+
if (!member.items.every((component) => component.value.type === "string")) continue;
|
|
228
|
+
const parameters = readParameters(member.parameters);
|
|
229
|
+
if (parameters === void 0) continue;
|
|
230
|
+
result.push({ label, components: member.items, parameters, signature: signature.value.value, raw: member });
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
function signatureBase(request, input) {
|
|
235
|
+
const url = new URL(request.url);
|
|
236
|
+
const lines = [];
|
|
237
|
+
const seen = /* @__PURE__ */ new Set();
|
|
238
|
+
for (const component of input.components) {
|
|
239
|
+
const identifier = serializeMember(component);
|
|
240
|
+
if (seen.has(identifier)) return void 0;
|
|
241
|
+
seen.add(identifier);
|
|
242
|
+
const value = componentValue(request, url, component);
|
|
243
|
+
if (value === void 0) return void 0;
|
|
244
|
+
lines.push(`${identifier}: ${value}`);
|
|
245
|
+
}
|
|
246
|
+
lines.push(`"@signature-params": ${serializeMember(input.raw)}`);
|
|
247
|
+
const base = lines.join("\n");
|
|
248
|
+
return SIGNATURE_BASE.test(base) ? base : void 0;
|
|
249
|
+
}
|
|
250
|
+
function componentValue(request, url, component) {
|
|
251
|
+
const name = stringValue(component.value);
|
|
252
|
+
if (name === void 0) return void 0;
|
|
253
|
+
const parameters = [...component.parameters];
|
|
254
|
+
if (name === "@query-param") {
|
|
255
|
+
const [only2, ...rest2] = parameters;
|
|
256
|
+
if (only2 === void 0 || only2[0] !== "name" || rest2.length > 0) return void 0;
|
|
257
|
+
const wanted = stringValue(only2[1]);
|
|
258
|
+
return wanted === void 0 ? void 0 : queryParameter(url, wanted);
|
|
259
|
+
}
|
|
260
|
+
if (name.startsWith("@")) {
|
|
261
|
+
return parameters.length === 0 ? derivedComponent(request, url, name) : void 0;
|
|
262
|
+
}
|
|
263
|
+
if (!FIELD_NAME.test(name)) return void 0;
|
|
264
|
+
const field = request.headers.get(name);
|
|
265
|
+
if (field === null) return void 0;
|
|
266
|
+
if (parameters.length === 0) return field;
|
|
267
|
+
const [only, ...rest] = parameters;
|
|
268
|
+
if (only?.[0] !== "key" || rest.length > 0) return void 0;
|
|
269
|
+
const key = stringValue(only[1]);
|
|
270
|
+
const member = key === void 0 ? void 0 : parseDictionary(field)?.get(key);
|
|
271
|
+
return member === void 0 ? void 0 : serializeMember(member);
|
|
272
|
+
}
|
|
273
|
+
function derivedComponent(request, url, name) {
|
|
274
|
+
switch (name) {
|
|
275
|
+
case "@method":
|
|
276
|
+
return request.method;
|
|
277
|
+
case "@target-uri":
|
|
278
|
+
return url.href;
|
|
279
|
+
case "@authority":
|
|
280
|
+
return url.host;
|
|
281
|
+
case "@scheme":
|
|
282
|
+
return url.protocol.slice(0, -1);
|
|
283
|
+
case "@request-target":
|
|
284
|
+
return `${url.pathname}${url.search}`;
|
|
285
|
+
case "@path":
|
|
286
|
+
return url.pathname === "" ? "/" : url.pathname;
|
|
287
|
+
case "@query":
|
|
288
|
+
return url.search === "" ? "?" : url.search;
|
|
289
|
+
default:
|
|
290
|
+
return void 0;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function queryParameter(url, wanted) {
|
|
294
|
+
const matches = [...new URLSearchParams(url.search)].filter(([name]) => formEncode(name) === wanted);
|
|
295
|
+
const [match, duplicate] = matches;
|
|
296
|
+
return match === void 0 || duplicate !== void 0 ? void 0 : formEncode(match[1]);
|
|
297
|
+
}
|
|
298
|
+
function formEncode(value) {
|
|
299
|
+
return new URLSearchParams([["", value]]).toString().slice(1).replace(/\+/g, "%20");
|
|
300
|
+
}
|
|
301
|
+
function readParameters(parameters) {
|
|
302
|
+
const values = /* @__PURE__ */ new Map();
|
|
303
|
+
for (const [key, value] of parameters) {
|
|
304
|
+
if (INTEGER_PARAMETERS.has(key) && value.type === "integer") values.set(key, value.value);
|
|
305
|
+
else if (STRING_PARAMETERS.has(key) && value.type === "string") values.set(key, value.value);
|
|
306
|
+
else return void 0;
|
|
307
|
+
}
|
|
308
|
+
const integer = (key) => {
|
|
309
|
+
const value = values.get(key);
|
|
310
|
+
return typeof value === "number" ? value : void 0;
|
|
311
|
+
};
|
|
312
|
+
const string = (key) => {
|
|
313
|
+
const value = values.get(key);
|
|
314
|
+
return typeof value === "string" ? value : void 0;
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
created: integer("created"),
|
|
318
|
+
expires: integer("expires"),
|
|
319
|
+
nonce: string("nonce"),
|
|
320
|
+
alg: string("alg"),
|
|
321
|
+
keyid: string("keyid"),
|
|
322
|
+
tag: string("tag")
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function stringValue(item) {
|
|
326
|
+
return item.type === "string" ? item.value : void 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/verification-key.ts
|
|
330
|
+
var SHAPES = {
|
|
331
|
+
"OKP Ed25519": { algorithm: "ed25519", names: ["ed25519", "EdDSA", "Ed25519"], members: ["crv", "kty", "x"], importParams: { name: "Ed25519" } },
|
|
332
|
+
"EC P-256": {
|
|
333
|
+
algorithm: "ecdsa-p256-sha256",
|
|
334
|
+
names: ["ecdsa-p256-sha256", "ES256"],
|
|
335
|
+
members: ["crv", "kty", "x", "y"],
|
|
336
|
+
importParams: { name: "ECDSA", namedCurve: "P-256" }
|
|
337
|
+
},
|
|
338
|
+
"EC P-384": {
|
|
339
|
+
algorithm: "ecdsa-p384-sha384",
|
|
340
|
+
names: ["ecdsa-p384-sha384", "ES384"],
|
|
341
|
+
members: ["crv", "kty", "x", "y"],
|
|
342
|
+
importParams: { name: "ECDSA", namedCurve: "P-384" }
|
|
343
|
+
},
|
|
344
|
+
RSA: { algorithm: "rsa-pss-sha512", names: ["rsa-pss-sha512", "PS512"], members: ["e", "kty", "n"], importParams: { name: "RSA-PSS", hash: "SHA-512" } }
|
|
345
|
+
};
|
|
346
|
+
var VERIFY_PARAMS = {
|
|
347
|
+
ed25519: { name: "Ed25519" },
|
|
348
|
+
"ecdsa-p256-sha256": { name: "ECDSA", hash: "SHA-256" },
|
|
349
|
+
"ecdsa-p384-sha384": { name: "ECDSA", hash: "SHA-384" },
|
|
350
|
+
"rsa-pss-sha512": { name: "RSA-PSS", saltLength: 64 }
|
|
351
|
+
};
|
|
352
|
+
var SIGNATURE_LENGTH = {
|
|
353
|
+
ed25519: 64,
|
|
354
|
+
"ecdsa-p256-sha256": 64,
|
|
355
|
+
"ecdsa-p384-sha384": 96
|
|
356
|
+
};
|
|
357
|
+
var MIN_RSA_MODULUS_BYTES = 256;
|
|
358
|
+
async function readVerificationKey(entry) {
|
|
359
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return void 0;
|
|
360
|
+
const jwk = entry;
|
|
361
|
+
const shape = jwk.kty === "RSA" ? SHAPES.RSA : SHAPES[`${String(jwk.kty)} ${String(jwk.crv)}`];
|
|
362
|
+
if (shape === void 0 || "d" in jwk) return void 0;
|
|
363
|
+
if (jwk.use !== void 0 && jwk.use !== "sig") return void 0;
|
|
364
|
+
if (jwk.key_ops !== void 0 && !(Array.isArray(jwk.key_ops) && jwk.key_ops.includes("verify"))) return void 0;
|
|
365
|
+
if (jwk.alg !== void 0 && !(typeof jwk.alg === "string" && shape.names.includes(jwk.alg))) return void 0;
|
|
366
|
+
const members = {};
|
|
367
|
+
for (const member of shape.members) {
|
|
368
|
+
const value = jwk[member];
|
|
369
|
+
if (typeof value !== "string") return void 0;
|
|
370
|
+
members[member] = value;
|
|
371
|
+
}
|
|
372
|
+
if (shape.algorithm === "rsa-pss-sha512" && (decodeBase64url(members.n ?? "")?.length ?? 0) < MIN_RSA_MODULUS_BYTES) return void 0;
|
|
373
|
+
const thumbprint = await jwkThumbprint(members, shape.members);
|
|
374
|
+
if (jwk.kid !== void 0 && jwk.kid !== thumbprint) return void 0;
|
|
375
|
+
const cryptoKey = await importPublicKey(members, shape);
|
|
376
|
+
if (cryptoKey === void 0) return void 0;
|
|
377
|
+
return {
|
|
378
|
+
thumbprint,
|
|
379
|
+
algorithm: shape.algorithm,
|
|
380
|
+
explicit: jwk.alg !== void 0 || shape.algorithm !== "rsa-pss-sha512",
|
|
381
|
+
notBefore: typeof jwk.nbf === "number" ? jwk.nbf : void 0,
|
|
382
|
+
notAfter: typeof jwk.exp === "number" ? jwk.exp : void 0,
|
|
383
|
+
cryptoKey
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function agreedAlgorithm(key, alg) {
|
|
387
|
+
if (alg === void 0) return key.explicit ? key.algorithm : void 0;
|
|
388
|
+
return alg === key.algorithm ? key.algorithm : void 0;
|
|
389
|
+
}
|
|
390
|
+
async function verifySignature(key, algorithm, base, signature) {
|
|
391
|
+
const length = SIGNATURE_LENGTH[algorithm];
|
|
392
|
+
if (length !== void 0 && signature.length !== length) return false;
|
|
393
|
+
return crypto.subtle.verify(VERIFY_PARAMS[algorithm], key.cryptoKey, signature, new TextEncoder().encode(base));
|
|
394
|
+
}
|
|
395
|
+
async function jwkThumbprint(members, names) {
|
|
396
|
+
const canonical = JSON.stringify(Object.fromEntries(names.map((name) => [name, members[name]])));
|
|
397
|
+
return encodeBase64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical))));
|
|
398
|
+
}
|
|
399
|
+
async function importPublicKey(members, shape) {
|
|
400
|
+
try {
|
|
401
|
+
return await crypto.subtle.importKey("jwk", { ...members, ext: true }, shape.importParams, false, ["verify"]);
|
|
402
|
+
} catch {
|
|
403
|
+
return void 0;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// src/key-directory.ts
|
|
408
|
+
var DIRECTORY_PATH = "/.well-known/http-message-signatures-directory";
|
|
409
|
+
var MEDIA_TYPE = "application/http-message-signatures-directory+json";
|
|
410
|
+
var MAX_BYTES = 64 * 1024;
|
|
411
|
+
var MAX_KEYS = 32;
|
|
412
|
+
var MAX_DIRECTORIES = 1024;
|
|
413
|
+
var MIN_TTL_MS = 6e4;
|
|
414
|
+
var RETRY_AFTER_FAILURE_MS = 3e4;
|
|
415
|
+
var STALE_IF_ERROR_MS = 24 * 60 * 60 * 1e3;
|
|
416
|
+
function keyDirectory(options) {
|
|
417
|
+
const entries = /* @__PURE__ */ new Map();
|
|
418
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
419
|
+
const remember = (origin, entry) => {
|
|
420
|
+
entries.delete(origin);
|
|
421
|
+
if (entries.size >= MAX_DIRECTORIES) {
|
|
422
|
+
const oldest = entries.keys().next();
|
|
423
|
+
if (oldest.done !== true) entries.delete(oldest.value);
|
|
424
|
+
}
|
|
425
|
+
entries.set(origin, entry);
|
|
426
|
+
};
|
|
427
|
+
const cached = (entry, at, failure) => {
|
|
428
|
+
if (entry?.keys == null || at >= entry.expiresAt + STALE_IF_ERROR_MS) return { status: failure };
|
|
429
|
+
return { status: "resolved", keys: entry.keys };
|
|
430
|
+
};
|
|
431
|
+
const fetchOnce = (origin, signal) => {
|
|
432
|
+
const pending = inFlight.get(origin);
|
|
433
|
+
if (pending !== void 0) return pending;
|
|
434
|
+
const started = fetchDirectory(origin, options, signal).finally(() => inFlight.delete(origin));
|
|
435
|
+
inFlight.set(origin, started);
|
|
436
|
+
return started;
|
|
437
|
+
};
|
|
438
|
+
return {
|
|
439
|
+
async lookup(origin, now, signal) {
|
|
440
|
+
const at = now.getTime();
|
|
441
|
+
const entry = entries.get(origin);
|
|
442
|
+
if (entry?.keys != null && at < entry.expiresAt) return { status: "resolved", keys: entry.keys };
|
|
443
|
+
if (entry?.failure != null && at < entry.retryAt) return cached(entry, at, entry.failure);
|
|
444
|
+
const fetched = await fetchOnce(origin, signal);
|
|
445
|
+
if (typeof fetched === "string") {
|
|
446
|
+
remember(origin, {
|
|
447
|
+
keys: entry?.keys ?? null,
|
|
448
|
+
expiresAt: entry?.expiresAt ?? at,
|
|
449
|
+
retryAt: at + RETRY_AFTER_FAILURE_MS,
|
|
450
|
+
failure: fetched
|
|
451
|
+
});
|
|
452
|
+
return cached(entry, at, fetched);
|
|
453
|
+
}
|
|
454
|
+
remember(origin, { keys: fetched.keys, expiresAt: at + Math.min(options.cacheTtlMs, fetched.ttlMs), retryAt: 0, failure: null });
|
|
455
|
+
return { status: "resolved", keys: fetched.keys };
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async function fetchDirectory(origin, options, signal) {
|
|
460
|
+
const response = await readBounded(`${origin}${DIRECTORY_PATH}`, options, signal);
|
|
461
|
+
if (typeof response === "string") return response;
|
|
462
|
+
const document = parseJson(response.body);
|
|
463
|
+
if (typeof document !== "object" || document === null || !("keys" in document) || !Array.isArray(document.keys)) return "invalid";
|
|
464
|
+
const entries = document.keys;
|
|
465
|
+
if (entries.length > MAX_KEYS) return "invalid";
|
|
466
|
+
const keys = /* @__PURE__ */ new Map();
|
|
467
|
+
for (const entry of entries) {
|
|
468
|
+
const key = await readVerificationKey(entry);
|
|
469
|
+
if (key !== void 0) keys.set(key.thumbprint, key);
|
|
470
|
+
}
|
|
471
|
+
return { keys, ttlMs: Math.max(MIN_TTL_MS, maxAgeMs(response.cacheControl) ?? Number.POSITIVE_INFINITY) };
|
|
472
|
+
}
|
|
473
|
+
async function readBounded(url, options, signal) {
|
|
474
|
+
try {
|
|
475
|
+
const response = await options.fetch(url, {
|
|
476
|
+
method: "GET",
|
|
477
|
+
redirect: "manual",
|
|
478
|
+
headers: { accept: MEDIA_TYPE },
|
|
479
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(options.timeoutMs)])
|
|
480
|
+
});
|
|
481
|
+
if (response.status !== 200 || response.body === null) {
|
|
482
|
+
await response.body?.cancel();
|
|
483
|
+
return response.status >= 500 ? "unavailable" : "invalid";
|
|
484
|
+
}
|
|
485
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
486
|
+
if (declared > MAX_BYTES) {
|
|
487
|
+
await response.body.cancel();
|
|
488
|
+
return "invalid";
|
|
489
|
+
}
|
|
490
|
+
const reader = response.body.getReader();
|
|
491
|
+
const chunks = [];
|
|
492
|
+
let total = 0;
|
|
493
|
+
for (; ; ) {
|
|
494
|
+
const { done, value } = await reader.read();
|
|
495
|
+
if (done) break;
|
|
496
|
+
total += value.length;
|
|
497
|
+
if (total > MAX_BYTES) {
|
|
498
|
+
await reader.cancel();
|
|
499
|
+
return "invalid";
|
|
500
|
+
}
|
|
501
|
+
chunks.push(value);
|
|
502
|
+
}
|
|
503
|
+
const bytes = new Uint8Array(total);
|
|
504
|
+
let offset = 0;
|
|
505
|
+
for (const chunk of chunks) {
|
|
506
|
+
bytes.set(chunk, offset);
|
|
507
|
+
offset += chunk.length;
|
|
508
|
+
}
|
|
509
|
+
return { body: new TextDecoder().decode(bytes), cacheControl: response.headers.get("cache-control") };
|
|
510
|
+
} catch {
|
|
511
|
+
return "unavailable";
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function maxAgeMs(cacheControl) {
|
|
515
|
+
if (cacheControl === null) return void 0;
|
|
516
|
+
if (/(?:^|,)\s*(?:no-store|no-cache)\s*(?:,|$)/i.test(cacheControl)) return 0;
|
|
517
|
+
const match = /(?:^|,)\s*max-age\s*=\s*(\d+)\s*(?:,|$)/i.exec(cacheControl);
|
|
518
|
+
return match?.[1] === void 0 ? void 0 : Number(match[1]) * 1e3;
|
|
519
|
+
}
|
|
520
|
+
function parseJson(text) {
|
|
521
|
+
try {
|
|
522
|
+
return JSON.parse(text);
|
|
523
|
+
} catch {
|
|
524
|
+
return void 0;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/agent-signature.ts
|
|
529
|
+
var WEB_BOT_AUTH_TAG = "web-bot-auth";
|
|
530
|
+
async function verifyAgentSignature(request, options) {
|
|
531
|
+
const signatures = readSignatures(request.headers);
|
|
532
|
+
if (typeof signatures === "string") return rejected(signatures);
|
|
533
|
+
const candidates = signatures.filter((signature) => signature.parameters.tag === WEB_BOT_AUTH_TAG);
|
|
534
|
+
if (candidates.length === 0) return rejected(signatures.length === 0 ? "signature_malformed" : "tag_missing");
|
|
535
|
+
let first;
|
|
536
|
+
for (const candidate of candidates) {
|
|
537
|
+
const result = await verifyCandidate(request, candidate, options);
|
|
538
|
+
if (result.status === "verified") return result;
|
|
539
|
+
first ??= result;
|
|
540
|
+
}
|
|
541
|
+
return first ?? rejected("signature_missing");
|
|
542
|
+
}
|
|
543
|
+
async function verifyCandidate(request, input, options) {
|
|
544
|
+
const { created, expires, keyid, nonce, alg } = input.parameters;
|
|
545
|
+
if (created === void 0 || expires === void 0 || keyid === void 0) return rejected("signature_parameters_missing");
|
|
546
|
+
if (created > expires) return rejected("signature_malformed");
|
|
547
|
+
if (!input.components.some(isBareTarget)) return rejected("target_not_covered");
|
|
548
|
+
const now = options.now.getTime() / 1e3;
|
|
549
|
+
const skew = options.clockSkewMs / 1e3;
|
|
550
|
+
if (created > now + skew) return rejected("signature_not_yet_valid");
|
|
551
|
+
if (now >= expires + skew) return rejected("signature_expired");
|
|
552
|
+
if (now - created > options.maxAgeMs / 1e3 + skew) return rejected("signature_too_old");
|
|
553
|
+
if (options.requireNonce && nonce === void 0) return rejected("nonce_missing");
|
|
554
|
+
const agent = coveredAgent(request, input.components);
|
|
555
|
+
if (typeof agent === "string") return rejected(agent);
|
|
556
|
+
const base = signatureBase(request, input);
|
|
557
|
+
if (base === void 0) return rejected("signature_base_invalid");
|
|
558
|
+
const resolution = await options.resolveKey(agent, keyid);
|
|
559
|
+
if (resolution.status === "rejected") return resolution;
|
|
560
|
+
const { key } = resolution;
|
|
561
|
+
if (key.notBefore !== void 0 && now + skew < key.notBefore || key.notAfter !== void 0 && now - skew >= key.notAfter) {
|
|
562
|
+
return rejected("key_not_found");
|
|
563
|
+
}
|
|
564
|
+
const algorithm = agreedAlgorithm(key, alg);
|
|
565
|
+
if (algorithm === void 0) return rejected("algorithm_mismatch");
|
|
566
|
+
if (!await verifySignature(key, algorithm, base, input.signature)) return rejected("signature_invalid");
|
|
567
|
+
return {
|
|
568
|
+
status: "verified",
|
|
569
|
+
agent,
|
|
570
|
+
keyid,
|
|
571
|
+
nonce,
|
|
572
|
+
acceptableUntil: Math.min(expires, created + options.maxAgeMs / 1e3) + skew
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function coveredAgent(request, components) {
|
|
576
|
+
const field = request.headers.get("signature-agent");
|
|
577
|
+
if (field === null) return "signature_agent_missing";
|
|
578
|
+
const covering = components.filter((component) => component.value.value === "signature-agent");
|
|
579
|
+
const legacy = parseItem(field);
|
|
580
|
+
if (legacy !== void 0) {
|
|
581
|
+
if (legacy.value.type !== "string" || legacy.parameters.size > 0) return "signature_agent_malformed";
|
|
582
|
+
if (!covering.some((component) => component.parameters.size === 0)) return "signature_agent_not_covered";
|
|
583
|
+
return directoryAgent(legacy.value.value) ?? "signature_agent_malformed";
|
|
584
|
+
}
|
|
585
|
+
const members = parseDictionary(field);
|
|
586
|
+
if (members === void 0) return "signature_agent_malformed";
|
|
587
|
+
const keys = covering.flatMap((component) => {
|
|
588
|
+
const key2 = component.parameters.get("key");
|
|
589
|
+
return component.parameters.size === 1 && key2?.type === "string" ? [key2.value] : [];
|
|
590
|
+
});
|
|
591
|
+
const [key, another] = keys;
|
|
592
|
+
if (key === void 0 || another !== void 0) return "signature_agent_not_covered";
|
|
593
|
+
const member = members.get(key);
|
|
594
|
+
if (member?.kind !== "item" || member.value.type !== "string") return "signature_agent_malformed";
|
|
595
|
+
const type = member.parameters.get("type");
|
|
596
|
+
if (type !== void 0 && !(type.type === "token" && type.value === "directory")) return "signature_agent_unsupported";
|
|
597
|
+
return directoryAgent(member.value.value) ?? "signature_agent_malformed";
|
|
598
|
+
}
|
|
599
|
+
function directoryAgent(value) {
|
|
600
|
+
const origin = httpsOrigin(value);
|
|
601
|
+
return origin === void 0 ? void 0 : { origin, directory: `${origin}${DIRECTORY_PATH}` };
|
|
602
|
+
}
|
|
603
|
+
function httpsOrigin(value) {
|
|
604
|
+
if (!URL.canParse(value)) return void 0;
|
|
605
|
+
const url = new URL(value);
|
|
606
|
+
if (url.protocol !== "https:") return void 0;
|
|
607
|
+
return value === url.origin || value === `${url.origin}/` ? url.origin : void 0;
|
|
608
|
+
}
|
|
609
|
+
function isBareTarget(component) {
|
|
610
|
+
return (component.value.value === "@authority" || component.value.value === "@target-uri") && component.parameters.size === 0;
|
|
611
|
+
}
|
|
612
|
+
function rejected(reason) {
|
|
613
|
+
return { status: "rejected", reason };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// src/verified-agent.ts
|
|
617
|
+
var TEST_KEYS = /* @__PURE__ */ new Set([
|
|
618
|
+
"poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U",
|
|
619
|
+
"oD0HwocPBSfpNy5W3bpJeyFGY_IQ_YpqxSjQ3Yd-CLA",
|
|
620
|
+
"ydQXMtvbsOsZyFir-Y7A8t7fKEM1gbKPvyFkdpu4fvI"
|
|
621
|
+
]);
|
|
622
|
+
var NONCE_SCOPE = "web-bot-auth";
|
|
623
|
+
function verifiedAgent(options) {
|
|
624
|
+
const trusted = trustPredicate(options.trust);
|
|
625
|
+
const maxAgeMs2 = duration("maxAgeMs", options.maxAgeMs, 3e5);
|
|
626
|
+
const clockSkewMs = duration("clockSkewMs", options.clockSkewMs, 5e3, true);
|
|
627
|
+
const directory = keyDirectory({
|
|
628
|
+
fetch: options.fetch ?? ((input, init) => fetch(input, init)),
|
|
629
|
+
timeoutMs: duration("timeoutMs", options.timeoutMs, 3e3),
|
|
630
|
+
cacheTtlMs: duration("cacheTtlMs", options.cacheTtlMs, 36e5)
|
|
631
|
+
});
|
|
632
|
+
const resolveKey = async (agent, keyid, now, signal) => {
|
|
633
|
+
if (!trusted(agent.origin)) return { status: "rejected", reason: "agent_untrusted" };
|
|
634
|
+
const lookup = await directory.lookup(agent.origin, now, signal);
|
|
635
|
+
if (lookup.status !== "resolved") return { status: "rejected", reason: `directory_${lookup.status}` };
|
|
636
|
+
const key = lookup.keys.get(keyid);
|
|
637
|
+
if (key === void 0 || TEST_KEYS.has(key.thumbprint)) return { status: "rejected", reason: "key_not_found" };
|
|
638
|
+
return { status: "found", key };
|
|
639
|
+
};
|
|
640
|
+
return {
|
|
641
|
+
name: "verified-agent",
|
|
642
|
+
async check({ context, claims, now, signal }) {
|
|
643
|
+
if (context.transport !== "http" || context.request === null) return deny("http_request_required");
|
|
644
|
+
const verification = await verifyAgentSignature(context.request, {
|
|
645
|
+
now,
|
|
646
|
+
maxAgeMs: maxAgeMs2,
|
|
647
|
+
clockSkewMs,
|
|
648
|
+
requireNonce: options.requireNonce ?? false,
|
|
649
|
+
resolveKey: (agent, keyid) => resolveKey(agent, keyid, now, signal)
|
|
650
|
+
});
|
|
651
|
+
if (verification.status === "rejected" && verification.reason === "directory_unavailable") {
|
|
652
|
+
return { ok: false, status: 503, reason: verification.reason };
|
|
653
|
+
}
|
|
654
|
+
if (verification.status === "rejected") return deny(verification.reason);
|
|
655
|
+
if (verification.nonce !== void 0) {
|
|
656
|
+
const key = `${verification.agent.directory} ${verification.keyid} ${verification.nonce}`;
|
|
657
|
+
const claimed = await claims.claim(NONCE_SCOPE, key, new Date(verification.acceptableUntil * 1e3));
|
|
658
|
+
if (claimed === "exists") return deny("nonce_replayed");
|
|
659
|
+
}
|
|
660
|
+
return { ok: true };
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
function trustPredicate(trust) {
|
|
665
|
+
if (typeof trust === "function") return trust;
|
|
666
|
+
const origins = new Set(
|
|
667
|
+
trust.map((entry) => {
|
|
668
|
+
const origin = httpsOrigin(entry);
|
|
669
|
+
if (origin === void 0) {
|
|
670
|
+
throw new TollstileError(
|
|
671
|
+
"CONFIG_INVALID",
|
|
672
|
+
`verifiedAgent() trust entry "${entry}" is not an HTTPS origin. Use the lowercase origin only, e.g. "https://agent.example".`
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
return origin;
|
|
676
|
+
})
|
|
677
|
+
);
|
|
678
|
+
if (origins.size === 0) {
|
|
679
|
+
throw new TollstileError("CONFIG_INVALID", "verifiedAgent() needs at least one trusted origin, or a trust predicate.");
|
|
680
|
+
}
|
|
681
|
+
return (origin) => origins.has(origin);
|
|
682
|
+
}
|
|
683
|
+
function duration(name, value, fallback, allowZero = false) {
|
|
684
|
+
if (value === void 0) return fallback;
|
|
685
|
+
if (!Number.isSafeInteger(value) || value < 0 || value === 0 && !allowZero) {
|
|
686
|
+
throw new TollstileError("CONFIG_INVALID", `verifiedAgent() ${name} must be a positive integer number of milliseconds, got ${String(value)}.`);
|
|
687
|
+
}
|
|
688
|
+
return value;
|
|
689
|
+
}
|
|
690
|
+
function deny(reason) {
|
|
691
|
+
return { ok: false, status: 403, reason };
|
|
692
|
+
}
|
|
693
|
+
export {
|
|
694
|
+
verifiedAgent
|
|
695
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tollstile/web-bot-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Allow only verified AI agents: verify Web Bot Auth HTTP message signatures (RFC 9421) before an agent can pay or access a route. A Tollstile requirement.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"web-bot-auth",
|
|
7
|
+
"http-message-signatures",
|
|
8
|
+
"rfc9421",
|
|
9
|
+
"agent-identity",
|
|
10
|
+
"verified-agents",
|
|
11
|
+
"bot-authentication",
|
|
12
|
+
"ai-agents",
|
|
13
|
+
"agent-payments"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://tollstile.com",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/tollstile/tollstile.git",
|
|
19
|
+
"directory": "packages/web-bot-auth"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/tollstile/tollstile/issues"
|
|
23
|
+
},
|
|
24
|
+
"author": "Paradigm AI Inc.",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"tollstile": "^0.1.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"tollstile": "^0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
46
|
+
}
|
|
47
|
+
}
|