@adcp/sdk 13.0.0-rc.4 → 13.0.0-rc.6
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/README.md +7 -1
- package/bin/adcp.js +7 -1
- package/dist/lib/adapters/property-list-adapter.d.mts +5 -1
- package/dist/lib/adapters/property-list-adapter.d.ts +5 -1
- package/dist/lib/adapters/property-list-adapter.d.ts.map +1 -1
- package/dist/lib/adapters/property-list-adapter.js +7 -7
- package/dist/lib/adapters/property-list-adapter.js.map +1 -1
- package/dist/lib/adapters/property-list-adapter.mjs +7 -7
- package/dist/lib/adapters/property-list-adapter.mjs.map +1 -1
- package/dist/lib/auth/oauth/CLIFlowHandler.d.mts +10 -3
- package/dist/lib/auth/oauth/CLIFlowHandler.d.ts +10 -3
- package/dist/lib/auth/oauth/CLIFlowHandler.d.ts.map +1 -1
- package/dist/lib/auth/oauth/CLIFlowHandler.js +40 -4
- package/dist/lib/auth/oauth/CLIFlowHandler.js.map +1 -1
- package/dist/lib/auth/oauth/CLIFlowHandler.mjs +40 -4
- package/dist/lib/auth/oauth/CLIFlowHandler.mjs.map +1 -1
- package/dist/lib/auth/oauth/index.d.mts +1 -1
- package/dist/lib/auth/oauth/index.d.ts +1 -1
- package/dist/lib/auth/oauth/index.d.ts.map +1 -1
- package/dist/lib/auth/oauth/index.js +2 -0
- package/dist/lib/auth/oauth/index.js.map +1 -1
- package/dist/lib/auth/oauth/index.mjs +2 -0
- package/dist/lib/auth/oauth/index.mjs.map +1 -1
- package/dist/lib/auth/oauth/web-flow.d.mts +12 -0
- package/dist/lib/auth/oauth/web-flow.d.ts +12 -0
- package/dist/lib/auth/oauth/web-flow.d.ts.map +1 -1
- package/dist/lib/auth/oauth/web-flow.js +20 -2
- package/dist/lib/auth/oauth/web-flow.js.map +1 -1
- package/dist/lib/auth/oauth/web-flow.mjs +19 -2
- package/dist/lib/auth/oauth/web-flow.mjs.map +1 -1
- package/dist/lib/core/AgentClient.d.ts.map +1 -1
- package/dist/lib/core/SingleAgentClient.d.mts +27 -1
- package/dist/lib/core/SingleAgentClient.d.ts +27 -1
- package/dist/lib/core/SingleAgentClient.d.ts.map +1 -1
- package/dist/lib/core/SingleAgentClient.js +46 -18
- package/dist/lib/core/SingleAgentClient.js.map +1 -1
- package/dist/lib/core/SingleAgentClient.mjs +46 -18
- package/dist/lib/core/SingleAgentClient.mjs.map +1 -1
- package/dist/lib/net/address-guards.d.ts.map +1 -1
- package/dist/lib/net/address-guards.js +4 -0
- package/dist/lib/net/address-guards.js.map +1 -1
- package/dist/lib/net/address-guards.mjs +4 -0
- package/dist/lib/net/address-guards.mjs.map +1 -1
- package/dist/lib/protocols/a2a.js +2 -2
- package/dist/lib/protocols/a2a.js.map +1 -1
- package/dist/lib/protocols/a2a.mjs +2 -2
- package/dist/lib/protocols/a2a.mjs.map +1 -1
- package/dist/lib/protocols/index.d.ts.map +1 -1
- package/dist/lib/protocols/index.js +28 -7
- package/dist/lib/protocols/index.js.map +1 -1
- package/dist/lib/protocols/index.mjs +28 -7
- package/dist/lib/protocols/index.mjs.map +1 -1
- package/dist/lib/protocols/mcp-modern.d.ts.map +1 -1
- package/dist/lib/protocols/mcp-modern.js +18 -5
- package/dist/lib/protocols/mcp-modern.js.map +1 -1
- package/dist/lib/protocols/mcp-modern.mjs +18 -5
- package/dist/lib/protocols/mcp-modern.mjs.map +1 -1
- package/dist/lib/protocols/mcp-tasks.d.ts.map +1 -1
- package/dist/lib/protocols/mcp-tasks.js +2 -12
- package/dist/lib/protocols/mcp-tasks.js.map +1 -1
- package/dist/lib/protocols/mcp-tasks.mjs +2 -12
- package/dist/lib/protocols/mcp-tasks.mjs.map +1 -1
- package/dist/lib/protocols/mcp.js +2 -2
- package/dist/lib/protocols/mcp.js.map +1 -1
- package/dist/lib/protocols/mcp.mjs +2 -2
- package/dist/lib/protocols/mcp.mjs.map +1 -1
- package/dist/lib/registry/types.generated.d.mts +554 -15
- package/dist/lib/registry/types.generated.d.ts +554 -15
- package/dist/lib/registry/types.generated.d.ts.map +1 -1
- package/dist/lib/registry/types.generated.js.map +1 -1
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/server/decisioning/account.d.mts +6 -4
- package/dist/lib/server/decisioning/account.d.ts +6 -4
- package/dist/lib/server/decisioning/account.d.ts.map +1 -1
- package/dist/lib/server/decisioning/account.js.map +1 -1
- package/dist/lib/server/decisioning/account.mjs.map +1 -1
- package/dist/lib/server/decisioning/manifest-helpers.d.mts +6 -5
- package/dist/lib/server/decisioning/manifest-helpers.d.ts +6 -5
- package/dist/lib/server/decisioning/manifest-helpers.d.ts.map +1 -1
- package/dist/lib/server/decisioning/manifest-helpers.js.map +1 -1
- package/dist/lib/server/decisioning/manifest-helpers.mjs.map +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.js +4 -2
- package/dist/lib/server/decisioning/runtime/from-platform.js.map +1 -1
- package/dist/lib/server/decisioning/runtime/from-platform.mjs +4 -2
- package/dist/lib/server/decisioning/runtime/from-platform.mjs.map +1 -1
- package/dist/lib/server/decisioning/tenant-store.d.mts +26 -14
- package/dist/lib/server/decisioning/tenant-store.d.ts +26 -14
- package/dist/lib/server/decisioning/tenant-store.d.ts.map +1 -1
- package/dist/lib/server/decisioning/tenant-store.js +6 -1
- package/dist/lib/server/decisioning/tenant-store.js.map +1 -1
- package/dist/lib/server/decisioning/tenant-store.mjs +6 -1
- package/dist/lib/server/decisioning/tenant-store.mjs.map +1 -1
- package/dist/lib/server/pin-and-bind-fetch.d.mts +2 -0
- package/dist/lib/server/pin-and-bind-fetch.d.ts +2 -0
- package/dist/lib/server/pin-and-bind-fetch.d.ts.map +1 -1
- package/dist/lib/server/pin-and-bind-fetch.js +2 -0
- package/dist/lib/server/pin-and-bind-fetch.js.map +1 -1
- package/dist/lib/server/pin-and-bind-fetch.mjs +2 -0
- package/dist/lib/server/pin-and-bind-fetch.mjs.map +1 -1
- package/dist/lib/server/structured-serialize.d.ts.map +1 -1
- package/dist/lib/server/structured-serialize.js +10 -2
- package/dist/lib/server/structured-serialize.js.map +1 -1
- package/dist/lib/server/structured-serialize.mjs +10 -2
- package/dist/lib/server/structured-serialize.mjs.map +1 -1
- package/dist/lib/server/webhook-emitter.d.ts.map +1 -1
- package/dist/lib/server/webhook-emitter.js +4 -2
- package/dist/lib/server/webhook-emitter.js.map +1 -1
- package/dist/lib/server/webhook-emitter.mjs +4 -2
- package/dist/lib/server/webhook-emitter.mjs.map +1 -1
- package/dist/lib/signing/middleware.d.mts +2 -1
- package/dist/lib/signing/middleware.d.ts +2 -1
- package/dist/lib/signing/middleware.d.ts.map +1 -1
- package/dist/lib/signing/middleware.js +26 -4
- package/dist/lib/signing/middleware.js.map +1 -1
- package/dist/lib/signing/middleware.mjs +26 -4
- package/dist/lib/signing/middleware.mjs.map +1 -1
- package/dist/lib/signing/webhook-verifier.d.ts.map +1 -1
- package/dist/lib/signing/webhook-verifier.js +4 -2
- package/dist/lib/signing/webhook-verifier.js.map +1 -1
- package/dist/lib/signing/webhook-verifier.mjs +4 -2
- package/dist/lib/signing/webhook-verifier.mjs.map +1 -1
- package/dist/lib/testing/agent-tester.d.ts.map +1 -1
- package/dist/lib/testing/agent-tester.js +46 -1
- package/dist/lib/testing/agent-tester.js.map +1 -1
- package/dist/lib/testing/agent-tester.mjs +46 -1
- package/dist/lib/testing/agent-tester.mjs.map +1 -1
- package/dist/lib/types/asset-instances.d.mts +8 -6
- package/dist/lib/types/asset-instances.d.ts +8 -6
- package/dist/lib/types/asset-instances.d.ts.map +1 -1
- package/dist/lib/types/asset-instances.js.map +1 -1
- package/dist/lib/types/index.d.ts.map +1 -1
- package/dist/lib/types/index.js.map +1 -1
- package/dist/lib/types/index.mjs.map +1 -1
- package/dist/lib/utils/capabilities.d.mts +2 -0
- package/dist/lib/utils/capabilities.d.ts +2 -0
- package/dist/lib/utils/capabilities.d.ts.map +1 -1
- package/dist/lib/utils/capabilities.js +2 -1
- package/dist/lib/utils/capabilities.js.map +1 -1
- package/dist/lib/utils/capabilities.mjs +2 -1
- package/dist/lib/utils/capabilities.mjs.map +1 -1
- package/dist/lib/utils/probe-policy.d.mts +0 -16
- package/dist/lib/utils/probe-policy.d.ts +0 -16
- package/dist/lib/utils/probe-policy.d.ts.map +1 -1
- package/dist/lib/utils/probe-policy.js +15 -1
- package/dist/lib/utils/probe-policy.js.map +1 -1
- package/dist/lib/utils/probe-policy.mjs +15 -1
- package/dist/lib/utils/probe-policy.mjs.map +1 -1
- package/dist/lib/utils/redact-args.d.mts +36 -0
- package/dist/lib/utils/redact-args.d.ts +37 -0
- package/dist/lib/utils/redact-args.d.ts.map +1 -0
- package/dist/lib/utils/redact-args.js +46 -0
- package/dist/lib/utils/redact-args.js.map +1 -0
- package/dist/lib/utils/redact-args.mjs +22 -0
- package/dist/lib/utils/redact-args.mjs.map +1 -0
- package/dist/lib/v2/projection/creative-delivery.js +1 -5
- package/dist/lib/v2/projection/creative-delivery.js.map +1 -1
- package/dist/lib/v2/projection/creative-delivery.mjs +1 -5
- package/dist/lib/v2/projection/creative-delivery.mjs.map +1 -1
- package/dist/lib/validation/index.d.mts +15 -1
- package/dist/lib/validation/index.d.ts +15 -1
- package/dist/lib/validation/index.d.ts.map +1 -1
- package/dist/lib/validation/index.js +4 -8
- package/dist/lib/validation/index.js.map +1 -1
- package/dist/lib/validation/index.mjs +4 -8
- package/dist/lib/validation/index.mjs.map +1 -1
- package/dist/lib/version.d.mts +3 -3
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/version.mjs +3 -3
- package/dist/lib/version.mjs.map +1 -1
- package/examples/hello_seller_adapter_multi_tenant.ts +7 -0
- package/package.json +3 -2
- package/skills/build-holdco-agent/SKILL.md +19 -0
- package/skills/cross-cutting.md +3 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../../src/lib/signing/middleware.ts"],"names":[],"mappings":"AAKA,OAAO,EAA0B,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAC/E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,OAAO,QAAQ,MAAM,CAAC;IACpB,UAAU,eAAe;QACvB;;;;;WAKG;QACH,cAAc,CAAC,EAAE,cAAc,CAAC;QAChC,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB;CACF;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../../src/lib/signing/middleware.ts"],"names":[],"mappings":"AAKA,OAAO,EAA0B,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAC/E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,OAAO,QAAQ,MAAM,CAAC;IACpB,UAAU,eAAe;QACvB;;;;;WAKG;QACH,cAAc,CAAC,EAAE,cAAc,CAAC;QAChC,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB;CACF;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;IACvD,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;IACzC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,wBAAyB,SAAQ,IAAI,CACpD,oBAAoB,EACpB,WAAW,GAAG,aAAa,GAAG,iBAAiB,CAChD;IACC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,oBAAoB,CAAC,aAAa,CAAC,CAAC;IAClD;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,oBAAoB,CAAC,iBAAiB,CAAC,CAAC;IAC1D;;;;;;;;;;;;;;OAcG;IACH,gBAAgB,EAAE,CAAC,GAAG,EAAE,WAAW,KAAK,MAAM,GAAG,SAAS,CAAC;IAC3D;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,KAAK,MAAM,CAAC;CACvC;AAED,KAAK,MAAM,GAAG,CAAC,GAAG,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;AAEtC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,wBAAwB,IAQnE,KAAK,WAAW,EAChB,KAAK;IAAE,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;QAAE,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK;YAAE,IAAI,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;SAAE,CAAA;KAAE,CAAA;CAAE,EACvG,MAAM,MAAM,KACX,OAAO,CAAC,IAAI,CAAC,CA0CjB"}
|
|
@@ -65,19 +65,41 @@ function createExpressVerifier(options) {
|
|
|
65
65
|
}
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
+
const BODY_BEARING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
68
69
|
function resolveRawBody(req) {
|
|
69
70
|
if (typeof req.rawBody === "string") return req.rawBody;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (Number.isFinite(contentLength) && contentLength > 0) {
|
|
71
|
+
if (Buffer.isBuffer(req.rawBody)) return req.rawBody.toString("utf8");
|
|
72
|
+
if (BODY_BEARING_METHODS.has(req.method?.toUpperCase() ?? "") || declaresBody(req) || hasParsedBody(req)) {
|
|
73
73
|
throw new import_errors.RequestSignatureError(
|
|
74
74
|
"request_signature_header_malformed",
|
|
75
75
|
1,
|
|
76
|
-
"req.rawBody is required for signed requests
|
|
76
|
+
"req.rawBody is required for signed requests that may carry a body. Capture it before any body parser runs, e.g. `app.use(express.json({ verify: adapter.rawBodyVerify }))` using `createExpressAdapter()`, or `app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } }))`. The capture middleware must be mounted ahead of createExpressVerifier."
|
|
77
77
|
);
|
|
78
78
|
}
|
|
79
79
|
return "";
|
|
80
80
|
}
|
|
81
|
+
function declaresBody(req) {
|
|
82
|
+
const length = (0, import_canonicalize.getHeaderValue)(req.headers, "content-length");
|
|
83
|
+
if (length !== void 0) {
|
|
84
|
+
const parsed = Number(length);
|
|
85
|
+
if (!Number.isFinite(parsed)) return true;
|
|
86
|
+
if (parsed !== 0) return true;
|
|
87
|
+
}
|
|
88
|
+
return (0, import_canonicalize.getHeaderValue)(req.headers, "transfer-encoding") !== void 0;
|
|
89
|
+
}
|
|
90
|
+
function hasParsedBody(req) {
|
|
91
|
+
const body = req.body;
|
|
92
|
+
if (body === void 0 || body === null) return false;
|
|
93
|
+
if (typeof body === "string") return body.length > 0;
|
|
94
|
+
if (Buffer.isBuffer(body)) return body.length > 0;
|
|
95
|
+
if (typeof body === "object") {
|
|
96
|
+
const proto = Object.getPrototypeOf(body);
|
|
97
|
+
const isPlainObject = proto === Object.prototype || proto === null;
|
|
98
|
+
if (!isPlainObject) return true;
|
|
99
|
+
return Object.keys(body).length > 0;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
81
103
|
function defaultUrl(req) {
|
|
82
104
|
const proto = req.protocol ?? (typeof req.get === "function" ? req.get("x-forwarded-proto") : void 0) ?? "https";
|
|
83
105
|
const host = typeof req.get === "function" ? req.get("host") : void 0;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/signing/middleware.ts"],"sourcesContent":["import type { RequestLike } from './canonicalize';\nimport { getHeaderValue } from './canonicalize';\nimport { RequestSignatureError } from './errors';\nimport { InMemoryReplayStore } from './replay';\nimport { InMemoryRevocationStore } from './revocation';\nimport { verifyRequestSignature, type VerifyRequestOptions } from './verifier';\nimport type { VerifiedSigner } from './types';\n\ndeclare module 'http' {\n interface IncomingMessage {\n /**\n * Populated by {@link createExpressVerifier} iff a real signature was\n * verified. Unsigned-but-acceptable requests leave this `undefined` so\n * downstream handlers can read `req.verifiedSigner !== undefined` as\n * \"signed and verified.\"\n */\n verifiedSigner?: VerifiedSigner;\n rawBody?: string;\n }\n}\n\nexport interface ExpressLike {\n method: string;\n originalUrl?: string;\n url: string;\n headers: Record<string, string | string[] | undefined>;\n rawBody?: string;\n body?: unknown;\n protocol?: string;\n get?(header: string): string | undefined;\n [key: string]: unknown;\n}\n\nexport interface ExpressMiddlewareOptions extends Omit<\n VerifyRequestOptions,\n 'operation' | 'replayStore' | 'revocationStore'\n> {\n /**\n * Stores `(keyid, signature-bytes, expires)` tuples for replay detection.\n * Defaults to a fresh {@link InMemoryReplayStore} — suitable for single-process\n * deployments only. **Multi-replica deployments MUST pass an explicit shared\n * store** (Redis, Postgres, etc.) — the default in-memory store does not\n * survive process boundaries, so a signature accepted on replica A is\n * invisible to replica B and can be replayed there within the signature window.\n */\n replayStore?: VerifyRequestOptions['replayStore'];\n /**\n * Consulted for revoked `kid` / `jti` before accepting a signature.\n * Defaults to a fresh {@link InMemoryRevocationStore}, which starts empty\n * and does not poll for updates. The default is sufficient when you don't\n * revoke keys at runtime; when you do, pass a store backed by your secrets\n * manager or admin tooling so revocations take effect without redeployment.\n */\n revocationStore?: VerifyRequestOptions['revocationStore'];\n /**\n * Extract the AdCP operation name from the incoming request so the verifier\n * can consult `capability.required_for`. Return `undefined` for requests\n * that don't map to an AdCP operation (health checks, discovery probes) —\n * the verifier will then treat the request as \"not in any required_for\"\n * and accept unsigned traffic rather than rejecting.\n *\n * SECURITY: a `resolveOperation` that always returns `undefined` — for\n * example, a routing helper that silently fails to match — disables\n * `required_for` enforcement globally. Unsigned requests on signed-only\n * operations will pass. Verify the implementation actually resolves the\n * operation name for every AdCP route you care to protect; a unit test\n * asserting `resolveOperation(req)` is non-undefined for sample signed\n * requests is the simplest guard.\n */\n resolveOperation: (req: ExpressLike) => string | undefined;\n /**\n * Override how the request's full URL is reconstructed. Use when the server\n * sits behind a TLS-terminating or path-rewriting load balancer, since\n * `req.protocol`/`req.get('host')` may not reflect what the signer signed.\n * Must return the exact URL the signer used (scheme + authority + path + query).\n */\n getUrl?: (req: ExpressLike) => string;\n}\n\ntype NextFn = (err?: unknown) => void;\n\nexport function createExpressVerifier(options: ExpressMiddlewareOptions) {\n // Instantiate defaults once at wire-up so every request on this middleware\n // shares the same replay/revocation state — lazy per-request construction\n // would defeat replay detection entirely.\n const replayStore = options.replayStore ?? new InMemoryReplayStore();\n const revocationStore = options.revocationStore ?? new InMemoryRevocationStore();\n\n return async function requestSignatureMiddleware(\n req: ExpressLike,\n res: { status: (code: number) => { set: (k: string, v: string) => { json: (body: unknown) => void } } },\n next: NextFn\n ): Promise<void> {\n try {\n const url = options.getUrl ? options.getUrl(req) : defaultUrl(req);\n const body = resolveRawBody(req);\n const requestLike: RequestLike = {\n method: req.method,\n url,\n headers: req.headers,\n body,\n };\n const result = await verifyRequestSignature(requestLike, {\n ...options,\n replayStore,\n revocationStore,\n operation: options.resolveOperation(req),\n });\n if (result.status === 'verified') {\n req.verifiedSigner = {\n keyid: result.keyid,\n agent_url: result.agent_url,\n verified_at: result.verified_at,\n };\n }\n next();\n } catch (err) {\n if (err instanceof RequestSignatureError) {\n // `failed_step` is informational per spec; keep it in server-side logs\n // rather than the 401 body so anonymous callers can't enumerate the\n // verifier pipeline. Do NOT call next() here — Express convention is\n // that a terminal response ends the middleware chain without\n // invoking downstream handlers. Promise-based wrappers that need to\n // await completion should race against the response's 'close' event\n // rather than the next-callback.\n res.status(401).set('WWW-Authenticate', `Signature error=\"${err.code}\"`).json({\n error: err.code,\n message: 'Request signature verification failed',\n });\n return;\n }\n next(err);\n }\n };\n}\n\nfunction resolveRawBody(req: ExpressLike): string {\n if (typeof req.rawBody === 'string') return req.rawBody;\n const contentLengthHeader = getHeaderValue(req.headers, 'content-length');\n const contentLength = contentLengthHeader ? Number(contentLengthHeader) : 0;\n if (Number.isFinite(contentLength) && contentLength > 0) {\n throw new RequestSignatureError(\n 'request_signature_header_malformed',\n 1,\n 'req.rawBody is required for signed requests with a body; install a raw-body capture middleware ahead of createExpressVerifier'\n );\n }\n return '';\n}\n\nfunction defaultUrl(req: ExpressLike): string {\n const proto = req.protocol ?? (typeof req.get === 'function' ? req.get('x-forwarded-proto') : undefined) ?? 'https';\n const host = typeof req.get === 'function' ? req.get('host') : undefined;\n if (!host) {\n throw new Error(\n 'Unable to derive request URL: missing Host header. Pass `getUrl` on createExpressVerifier to supply the canonical URL explicitly.'\n );\n }\n return `${proto}://${host}${req.originalUrl ?? req.url}`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,0BAA+B;AAC/B,oBAAsC;AACtC,oBAAoC;AACpC,wBAAwC;AACxC,sBAAkE;AA4E3D,SAAS,sBAAsB,SAAmC;AAIvE,QAAM,cAAc,QAAQ,eAAe,IAAI,kCAAoB;AACnE,QAAM,kBAAkB,QAAQ,mBAAmB,IAAI,0CAAwB;AAE/E,SAAO,eAAe,2BACpB,KACA,KACA,MACe;AACf,QAAI;AACF,YAAM,MAAM,QAAQ,SAAS,QAAQ,OAAO,GAAG,IAAI,WAAW,GAAG;AACjE,YAAM,OAAO,eAAe,GAAG;AAC/B,YAAM,cAA2B;AAAA,QAC/B,QAAQ,IAAI;AAAA,QACZ;AAAA,QACA,SAAS,IAAI;AAAA,QACb;AAAA,MACF;AACA,YAAM,SAAS,UAAM,wCAAuB,aAAa;AAAA,QACvD,GAAG;AAAA,QACH;AAAA,QACA;AAAA,QACA,WAAW,QAAQ,iBAAiB,GAAG;AAAA,MACzC,CAAC;AACD,UAAI,OAAO,WAAW,YAAY;AAChC,YAAI,iBAAiB;AAAA,UACnB,OAAO,OAAO;AAAA,UACd,WAAW,OAAO;AAAA,UAClB,aAAa,OAAO;AAAA,QACtB;AAAA,MACF;AACA,WAAK;AAAA,IACP,SAAS,KAAK;AACZ,UAAI,eAAe,qCAAuB;AAQxC,YAAI,OAAO,GAAG,EAAE,IAAI,oBAAoB,oBAAoB,IAAI,IAAI,GAAG,EAAE,KAAK;AAAA,UAC5E,OAAO,IAAI;AAAA,UACX,SAAS;AAAA,QACX,CAAC;AACD;AAAA,MACF;AACA,WAAK,GAAG;AAAA,IACV;AAAA,EACF;AACF;AAEA,SAAS,eAAe,KAA0B;AAChD,MAAI,OAAO,IAAI,YAAY,SAAU,QAAO,IAAI;AAChD,QAAM,0BAAsB,oCAAe,IAAI,SAAS,gBAAgB;AACxE,QAAM,gBAAgB,sBAAsB,OAAO,mBAAmB,IAAI;AAC1E,MAAI,OAAO,SAAS,aAAa,KAAK,gBAAgB,GAAG;AACvD,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,WAAW,KAA0B;AAC5C,QAAM,QAAQ,IAAI,aAAa,OAAO,IAAI,QAAQ,aAAa,IAAI,IAAI,mBAAmB,IAAI,WAAc;AAC5G,QAAM,OAAO,OAAO,IAAI,QAAQ,aAAa,IAAI,IAAI,MAAM,IAAI;AAC/D,MAAI,CAAC,MAAM;AACT,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,SAAO,GAAG,KAAK,MAAM,IAAI,GAAG,IAAI,eAAe,IAAI,GAAG;AACxD;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/signing/middleware.ts"],"sourcesContent":["import type { RequestLike } from './canonicalize';\nimport { getHeaderValue } from './canonicalize';\nimport { RequestSignatureError } from './errors';\nimport { InMemoryReplayStore } from './replay';\nimport { InMemoryRevocationStore } from './revocation';\nimport { verifyRequestSignature, type VerifyRequestOptions } from './verifier';\nimport type { VerifiedSigner } from './types';\n\ndeclare module 'http' {\n interface IncomingMessage {\n /**\n * Populated by {@link createExpressVerifier} iff a real signature was\n * verified. Unsigned-but-acceptable requests leave this `undefined` so\n * downstream handlers can read `req.verifiedSigner !== undefined` as\n * \"signed and verified.\"\n */\n verifiedSigner?: VerifiedSigner;\n rawBody?: string;\n }\n}\n\nexport interface ExpressLike {\n method: string;\n originalUrl?: string;\n url: string;\n headers: Record<string, string | string[] | undefined>;\n /** Raw request bytes captured before any body parser. String or Buffer. */\n rawBody?: string | Buffer;\n body?: unknown;\n protocol?: string;\n get?(header: string): string | undefined;\n [key: string]: unknown;\n}\n\nexport interface ExpressMiddlewareOptions extends Omit<\n VerifyRequestOptions,\n 'operation' | 'replayStore' | 'revocationStore'\n> {\n /**\n * Stores `(keyid, signature-bytes, expires)` tuples for replay detection.\n * Defaults to a fresh {@link InMemoryReplayStore} — suitable for single-process\n * deployments only. **Multi-replica deployments MUST pass an explicit shared\n * store** (Redis, Postgres, etc.) — the default in-memory store does not\n * survive process boundaries, so a signature accepted on replica A is\n * invisible to replica B and can be replayed there within the signature window.\n */\n replayStore?: VerifyRequestOptions['replayStore'];\n /**\n * Consulted for revoked `kid` / `jti` before accepting a signature.\n * Defaults to a fresh {@link InMemoryRevocationStore}, which starts empty\n * and does not poll for updates. The default is sufficient when you don't\n * revoke keys at runtime; when you do, pass a store backed by your secrets\n * manager or admin tooling so revocations take effect without redeployment.\n */\n revocationStore?: VerifyRequestOptions['revocationStore'];\n /**\n * Extract the AdCP operation name from the incoming request so the verifier\n * can consult `capability.required_for`. Return `undefined` for requests\n * that don't map to an AdCP operation (health checks, discovery probes) —\n * the verifier will then treat the request as \"not in any required_for\"\n * and accept unsigned traffic rather than rejecting.\n *\n * SECURITY: a `resolveOperation` that always returns `undefined` — for\n * example, a routing helper that silently fails to match — disables\n * `required_for` enforcement globally. Unsigned requests on signed-only\n * operations will pass. Verify the implementation actually resolves the\n * operation name for every AdCP route you care to protect; a unit test\n * asserting `resolveOperation(req)` is non-undefined for sample signed\n * requests is the simplest guard.\n */\n resolveOperation: (req: ExpressLike) => string | undefined;\n /**\n * Override how the request's full URL is reconstructed. Use when the server\n * sits behind a TLS-terminating or path-rewriting load balancer, since\n * `req.protocol`/`req.get('host')` may not reflect what the signer signed.\n * Must return the exact URL the signer used (scheme + authority + path + query).\n */\n getUrl?: (req: ExpressLike) => string;\n}\n\ntype NextFn = (err?: unknown) => void;\n\nexport function createExpressVerifier(options: ExpressMiddlewareOptions) {\n // Instantiate defaults once at wire-up so every request on this middleware\n // shares the same replay/revocation state — lazy per-request construction\n // would defeat replay detection entirely.\n const replayStore = options.replayStore ?? new InMemoryReplayStore();\n const revocationStore = options.revocationStore ?? new InMemoryRevocationStore();\n\n return async function requestSignatureMiddleware(\n req: ExpressLike,\n res: { status: (code: number) => { set: (k: string, v: string) => { json: (body: unknown) => void } } },\n next: NextFn\n ): Promise<void> {\n try {\n const url = options.getUrl ? options.getUrl(req) : defaultUrl(req);\n const body = resolveRawBody(req);\n const requestLike: RequestLike = {\n method: req.method,\n url,\n headers: req.headers,\n body,\n };\n const result = await verifyRequestSignature(requestLike, {\n ...options,\n replayStore,\n revocationStore,\n operation: options.resolveOperation(req),\n });\n if (result.status === 'verified') {\n req.verifiedSigner = {\n keyid: result.keyid,\n agent_url: result.agent_url,\n verified_at: result.verified_at,\n };\n }\n next();\n } catch (err) {\n if (err instanceof RequestSignatureError) {\n // `failed_step` is informational per spec; keep it in server-side logs\n // rather than the 401 body so anonymous callers can't enumerate the\n // verifier pipeline. Do NOT call next() here — Express convention is\n // that a terminal response ends the middleware chain without\n // invoking downstream handlers. Promise-based wrappers that need to\n // await completion should race against the response's 'close' event\n // rather than the next-callback.\n res.status(401).set('WWW-Authenticate', `Signature error=\"${err.code}\"`).json({\n error: err.code,\n message: 'Request signature verification failed',\n });\n return;\n }\n next(err);\n }\n };\n}\n\n/** Methods that may carry a request body, so a missing `rawBody` is suspect. */\nconst BODY_BEARING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);\n\nfunction resolveRawBody(req: ExpressLike): string {\n // The canonical raw-body recipe (`express.json({ verify })`, and this SDK's\n // own `createExpressAdapter().rawBodyVerify`) may hand back either a string\n // or the raw Buffer, so accept both rather than 401-ing a correctly wired app.\n if (typeof req.rawBody === 'string') return req.rawBody;\n if (Buffer.isBuffer(req.rawBody)) return req.rawBody.toString('utf8');\n\n // Verifying against `''` when the request actually carried a body would\n // authenticate a surrogate: `validateCoveredComponents` derives `hasBody`\n // from this value (so the `content-type` requirement lapses) and the\n // `content-digest` check would describe the empty string, after which\n // `next()` hands a downstream parser's real body to the handler.\n //\n // So the test is for positive proof that NO body exists, not for evidence\n // that one does. Enumerating body signals cannot work: HTTP/2 forbids\n // `Transfer-Encoding` outright and makes `content-length` optional, so a\n // DATA-frame body carries neither header and would slip through a\n // signal-based check. Only a non-body-bearing method with an explicit\n // zero (or absent) length can honestly verify against `''`.\n if (BODY_BEARING_METHODS.has(req.method?.toUpperCase() ?? '') || declaresBody(req) || hasParsedBody(req)) {\n throw new RequestSignatureError(\n 'request_signature_header_malformed',\n 1,\n 'req.rawBody is required for signed requests that may carry a body. Capture it before any body ' +\n 'parser runs, e.g. `app.use(express.json({ verify: adapter.rawBodyVerify }))` using ' +\n \"`createExpressAdapter()`, or `app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } }))`. \" +\n 'The capture middleware must be mounted ahead of createExpressVerifier.'\n );\n }\n return '';\n}\n\n/**\n * A `Content-Length` that is present and non-zero, or unparseable. `00`, `+0`,\n * and `0.0` are all legitimately zero; anything `Number` cannot read is treated\n * as a body so the failure direction stays closed.\n */\nfunction declaresBody(req: ExpressLike): boolean {\n const length = getHeaderValue(req.headers, 'content-length');\n if (length !== undefined) {\n const parsed = Number(length);\n if (!Number.isFinite(parsed)) return true;\n if (parsed !== 0) return true;\n }\n return getHeaderValue(req.headers, 'transfer-encoding') !== undefined;\n}\n\n/** A body parser mounted ahead of this middleware already consumed the stream. */\nfunction hasParsedBody(req: ExpressLike): boolean {\n const body = req.body;\n if (body === undefined || body === null) return false;\n if (typeof body === 'string') return body.length > 0;\n if (Buffer.isBuffer(body)) return body.length > 0;\n // Only a genuinely empty plain object counts as \"no body\" — Express sets\n // `req.body = {}` for bodiless requests. Anything else with no own enumerable\n // keys (URLSearchParams, Map, a class instance) may still hold data.\n if (typeof body === 'object') {\n const proto = Object.getPrototypeOf(body);\n const isPlainObject = proto === Object.prototype || proto === null;\n if (!isPlainObject) return true;\n return Object.keys(body as Record<string, unknown>).length > 0;\n }\n return true;\n}\n\nfunction defaultUrl(req: ExpressLike): string {\n const proto = req.protocol ?? (typeof req.get === 'function' ? req.get('x-forwarded-proto') : undefined) ?? 'https';\n const host = typeof req.get === 'function' ? req.get('host') : undefined;\n if (!host) {\n throw new Error(\n 'Unable to derive request URL: missing Host header. Pass `getUrl` on createExpressVerifier to supply the canonical URL explicitly.'\n );\n }\n return `${proto}://${host}${req.originalUrl ?? req.url}`;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -42,19 +42,41 @@ function createExpressVerifier(options) {
|
|
|
42
42
|
}
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
+
const BODY_BEARING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
45
46
|
function resolveRawBody(req) {
|
|
46
47
|
if (typeof req.rawBody === "string") return req.rawBody;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (Number.isFinite(contentLength) && contentLength > 0) {
|
|
48
|
+
if (Buffer.isBuffer(req.rawBody)) return req.rawBody.toString("utf8");
|
|
49
|
+
if (BODY_BEARING_METHODS.has(req.method?.toUpperCase() ?? "") || declaresBody(req) || hasParsedBody(req)) {
|
|
50
50
|
throw new RequestSignatureError(
|
|
51
51
|
"request_signature_header_malformed",
|
|
52
52
|
1,
|
|
53
|
-
"req.rawBody is required for signed requests
|
|
53
|
+
"req.rawBody is required for signed requests that may carry a body. Capture it before any body parser runs, e.g. `app.use(express.json({ verify: adapter.rawBodyVerify }))` using `createExpressAdapter()`, or `app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } }))`. The capture middleware must be mounted ahead of createExpressVerifier."
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
return "";
|
|
57
57
|
}
|
|
58
|
+
function declaresBody(req) {
|
|
59
|
+
const length = getHeaderValue(req.headers, "content-length");
|
|
60
|
+
if (length !== void 0) {
|
|
61
|
+
const parsed = Number(length);
|
|
62
|
+
if (!Number.isFinite(parsed)) return true;
|
|
63
|
+
if (parsed !== 0) return true;
|
|
64
|
+
}
|
|
65
|
+
return getHeaderValue(req.headers, "transfer-encoding") !== void 0;
|
|
66
|
+
}
|
|
67
|
+
function hasParsedBody(req) {
|
|
68
|
+
const body = req.body;
|
|
69
|
+
if (body === void 0 || body === null) return false;
|
|
70
|
+
if (typeof body === "string") return body.length > 0;
|
|
71
|
+
if (Buffer.isBuffer(body)) return body.length > 0;
|
|
72
|
+
if (typeof body === "object") {
|
|
73
|
+
const proto = Object.getPrototypeOf(body);
|
|
74
|
+
const isPlainObject = proto === Object.prototype || proto === null;
|
|
75
|
+
if (!isPlainObject) return true;
|
|
76
|
+
return Object.keys(body).length > 0;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
58
80
|
function defaultUrl(req) {
|
|
59
81
|
const proto = req.protocol ?? (typeof req.get === "function" ? req.get("x-forwarded-proto") : void 0) ?? "https";
|
|
60
82
|
const host = typeof req.get === "function" ? req.get("host") : void 0;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/signing/middleware.ts"],"sourcesContent":["import type { RequestLike } from './canonicalize';\nimport { getHeaderValue } from './canonicalize';\nimport { RequestSignatureError } from './errors';\nimport { InMemoryReplayStore } from './replay';\nimport { InMemoryRevocationStore } from './revocation';\nimport { verifyRequestSignature, type VerifyRequestOptions } from './verifier';\nimport type { VerifiedSigner } from './types';\n\ndeclare module 'http' {\n interface IncomingMessage {\n /**\n * Populated by {@link createExpressVerifier} iff a real signature was\n * verified. Unsigned-but-acceptable requests leave this `undefined` so\n * downstream handlers can read `req.verifiedSigner !== undefined` as\n * \"signed and verified.\"\n */\n verifiedSigner?: VerifiedSigner;\n rawBody?: string;\n }\n}\n\nexport interface ExpressLike {\n method: string;\n originalUrl?: string;\n url: string;\n headers: Record<string, string | string[] | undefined>;\n rawBody?: string;\n body?: unknown;\n protocol?: string;\n get?(header: string): string | undefined;\n [key: string]: unknown;\n}\n\nexport interface ExpressMiddlewareOptions extends Omit<\n VerifyRequestOptions,\n 'operation' | 'replayStore' | 'revocationStore'\n> {\n /**\n * Stores `(keyid, signature-bytes, expires)` tuples for replay detection.\n * Defaults to a fresh {@link InMemoryReplayStore} — suitable for single-process\n * deployments only. **Multi-replica deployments MUST pass an explicit shared\n * store** (Redis, Postgres, etc.) — the default in-memory store does not\n * survive process boundaries, so a signature accepted on replica A is\n * invisible to replica B and can be replayed there within the signature window.\n */\n replayStore?: VerifyRequestOptions['replayStore'];\n /**\n * Consulted for revoked `kid` / `jti` before accepting a signature.\n * Defaults to a fresh {@link InMemoryRevocationStore}, which starts empty\n * and does not poll for updates. The default is sufficient when you don't\n * revoke keys at runtime; when you do, pass a store backed by your secrets\n * manager or admin tooling so revocations take effect without redeployment.\n */\n revocationStore?: VerifyRequestOptions['revocationStore'];\n /**\n * Extract the AdCP operation name from the incoming request so the verifier\n * can consult `capability.required_for`. Return `undefined` for requests\n * that don't map to an AdCP operation (health checks, discovery probes) —\n * the verifier will then treat the request as \"not in any required_for\"\n * and accept unsigned traffic rather than rejecting.\n *\n * SECURITY: a `resolveOperation` that always returns `undefined` — for\n * example, a routing helper that silently fails to match — disables\n * `required_for` enforcement globally. Unsigned requests on signed-only\n * operations will pass. Verify the implementation actually resolves the\n * operation name for every AdCP route you care to protect; a unit test\n * asserting `resolveOperation(req)` is non-undefined for sample signed\n * requests is the simplest guard.\n */\n resolveOperation: (req: ExpressLike) => string | undefined;\n /**\n * Override how the request's full URL is reconstructed. Use when the server\n * sits behind a TLS-terminating or path-rewriting load balancer, since\n * `req.protocol`/`req.get('host')` may not reflect what the signer signed.\n * Must return the exact URL the signer used (scheme + authority + path + query).\n */\n getUrl?: (req: ExpressLike) => string;\n}\n\ntype NextFn = (err?: unknown) => void;\n\nexport function createExpressVerifier(options: ExpressMiddlewareOptions) {\n // Instantiate defaults once at wire-up so every request on this middleware\n // shares the same replay/revocation state — lazy per-request construction\n // would defeat replay detection entirely.\n const replayStore = options.replayStore ?? new InMemoryReplayStore();\n const revocationStore = options.revocationStore ?? new InMemoryRevocationStore();\n\n return async function requestSignatureMiddleware(\n req: ExpressLike,\n res: { status: (code: number) => { set: (k: string, v: string) => { json: (body: unknown) => void } } },\n next: NextFn\n ): Promise<void> {\n try {\n const url = options.getUrl ? options.getUrl(req) : defaultUrl(req);\n const body = resolveRawBody(req);\n const requestLike: RequestLike = {\n method: req.method,\n url,\n headers: req.headers,\n body,\n };\n const result = await verifyRequestSignature(requestLike, {\n ...options,\n replayStore,\n revocationStore,\n operation: options.resolveOperation(req),\n });\n if (result.status === 'verified') {\n req.verifiedSigner = {\n keyid: result.keyid,\n agent_url: result.agent_url,\n verified_at: result.verified_at,\n };\n }\n next();\n } catch (err) {\n if (err instanceof RequestSignatureError) {\n // `failed_step` is informational per spec; keep it in server-side logs\n // rather than the 401 body so anonymous callers can't enumerate the\n // verifier pipeline. Do NOT call next() here — Express convention is\n // that a terminal response ends the middleware chain without\n // invoking downstream handlers. Promise-based wrappers that need to\n // await completion should race against the response's 'close' event\n // rather than the next-callback.\n res.status(401).set('WWW-Authenticate', `Signature error=\"${err.code}\"`).json({\n error: err.code,\n message: 'Request signature verification failed',\n });\n return;\n }\n next(err);\n }\n };\n}\n\nfunction resolveRawBody(req: ExpressLike): string {\n if (typeof req.rawBody === 'string') return req.rawBody;\n const contentLengthHeader = getHeaderValue(req.headers, 'content-length');\n const contentLength = contentLengthHeader ? Number(contentLengthHeader) : 0;\n if (Number.isFinite(contentLength) && contentLength > 0) {\n throw new RequestSignatureError(\n 'request_signature_header_malformed',\n 1,\n 'req.rawBody is required for signed requests with a body; install a raw-body capture middleware ahead of createExpressVerifier'\n );\n }\n return '';\n}\n\nfunction defaultUrl(req: ExpressLike): string {\n const proto = req.protocol ?? (typeof req.get === 'function' ? req.get('x-forwarded-proto') : undefined) ?? 'https';\n const host = typeof req.get === 'function' ? req.get('host') : undefined;\n if (!host) {\n throw new Error(\n 'Unable to derive request URL: missing Host header. Pass `getUrl` on createExpressVerifier to supply the canonical URL explicitly.'\n );\n }\n return `${proto}://${host}${req.originalUrl ?? req.url}`;\n}\n"],"mappings":"AACA,SAAS,sBAAsB;AAC/B,SAAS,6BAA6B;AACtC,SAAS,2BAA2B;AACpC,SAAS,+BAA+B;AACxC,SAAS,8BAAyD;AA4E3D,SAAS,sBAAsB,SAAmC;AAIvE,QAAM,cAAc,QAAQ,eAAe,IAAI,oBAAoB;AACnE,QAAM,kBAAkB,QAAQ,mBAAmB,IAAI,wBAAwB;AAE/E,SAAO,eAAe,2BACpB,KACA,KACA,MACe;AACf,QAAI;AACF,YAAM,MAAM,QAAQ,SAAS,QAAQ,OAAO,GAAG,IAAI,WAAW,GAAG;AACjE,YAAM,OAAO,eAAe,GAAG;AAC/B,YAAM,cAA2B;AAAA,QAC/B,QAAQ,IAAI;AAAA,QACZ;AAAA,QACA,SAAS,IAAI;AAAA,QACb;AAAA,MACF;AACA,YAAM,SAAS,MAAM,uBAAuB,aAAa;AAAA,QACvD,GAAG;AAAA,QACH;AAAA,QACA;AAAA,QACA,WAAW,QAAQ,iBAAiB,GAAG;AAAA,MACzC,CAAC;AACD,UAAI,OAAO,WAAW,YAAY;AAChC,YAAI,iBAAiB;AAAA,UACnB,OAAO,OAAO;AAAA,UACd,WAAW,OAAO;AAAA,UAClB,aAAa,OAAO;AAAA,QACtB;AAAA,MACF;AACA,WAAK;AAAA,IACP,SAAS,KAAK;AACZ,UAAI,eAAe,uBAAuB;AAQxC,YAAI,OAAO,GAAG,EAAE,IAAI,oBAAoB,oBAAoB,IAAI,IAAI,GAAG,EAAE,KAAK;AAAA,UAC5E,OAAO,IAAI;AAAA,UACX,SAAS;AAAA,QACX,CAAC;AACD;AAAA,MACF;AACA,WAAK,GAAG;AAAA,IACV;AAAA,EACF;AACF;AAEA,SAAS,eAAe,KAA0B;AAChD,MAAI,OAAO,IAAI,YAAY,SAAU,QAAO,IAAI;AAChD,QAAM,sBAAsB,eAAe,IAAI,SAAS,gBAAgB;AACxE,QAAM,gBAAgB,sBAAsB,OAAO,mBAAmB,IAAI;AAC1E,MAAI,OAAO,SAAS,aAAa,KAAK,gBAAgB,GAAG;AACvD,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,WAAW,KAA0B;AAC5C,QAAM,QAAQ,IAAI,aAAa,OAAO,IAAI,QAAQ,aAAa,IAAI,IAAI,mBAAmB,IAAI,WAAc;AAC5G,QAAM,OAAO,OAAO,IAAI,QAAQ,aAAa,IAAI,IAAI,MAAM,IAAI;AAC/D,MAAI,CAAC,MAAM;AACT,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,SAAO,GAAG,KAAK,MAAM,IAAI,GAAG,IAAI,eAAe,IAAI,GAAG;AACxD;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/signing/middleware.ts"],"sourcesContent":["import type { RequestLike } from './canonicalize';\nimport { getHeaderValue } from './canonicalize';\nimport { RequestSignatureError } from './errors';\nimport { InMemoryReplayStore } from './replay';\nimport { InMemoryRevocationStore } from './revocation';\nimport { verifyRequestSignature, type VerifyRequestOptions } from './verifier';\nimport type { VerifiedSigner } from './types';\n\ndeclare module 'http' {\n interface IncomingMessage {\n /**\n * Populated by {@link createExpressVerifier} iff a real signature was\n * verified. Unsigned-but-acceptable requests leave this `undefined` so\n * downstream handlers can read `req.verifiedSigner !== undefined` as\n * \"signed and verified.\"\n */\n verifiedSigner?: VerifiedSigner;\n rawBody?: string;\n }\n}\n\nexport interface ExpressLike {\n method: string;\n originalUrl?: string;\n url: string;\n headers: Record<string, string | string[] | undefined>;\n /** Raw request bytes captured before any body parser. String or Buffer. */\n rawBody?: string | Buffer;\n body?: unknown;\n protocol?: string;\n get?(header: string): string | undefined;\n [key: string]: unknown;\n}\n\nexport interface ExpressMiddlewareOptions extends Omit<\n VerifyRequestOptions,\n 'operation' | 'replayStore' | 'revocationStore'\n> {\n /**\n * Stores `(keyid, signature-bytes, expires)` tuples for replay detection.\n * Defaults to a fresh {@link InMemoryReplayStore} — suitable for single-process\n * deployments only. **Multi-replica deployments MUST pass an explicit shared\n * store** (Redis, Postgres, etc.) — the default in-memory store does not\n * survive process boundaries, so a signature accepted on replica A is\n * invisible to replica B and can be replayed there within the signature window.\n */\n replayStore?: VerifyRequestOptions['replayStore'];\n /**\n * Consulted for revoked `kid` / `jti` before accepting a signature.\n * Defaults to a fresh {@link InMemoryRevocationStore}, which starts empty\n * and does not poll for updates. The default is sufficient when you don't\n * revoke keys at runtime; when you do, pass a store backed by your secrets\n * manager or admin tooling so revocations take effect without redeployment.\n */\n revocationStore?: VerifyRequestOptions['revocationStore'];\n /**\n * Extract the AdCP operation name from the incoming request so the verifier\n * can consult `capability.required_for`. Return `undefined` for requests\n * that don't map to an AdCP operation (health checks, discovery probes) —\n * the verifier will then treat the request as \"not in any required_for\"\n * and accept unsigned traffic rather than rejecting.\n *\n * SECURITY: a `resolveOperation` that always returns `undefined` — for\n * example, a routing helper that silently fails to match — disables\n * `required_for` enforcement globally. Unsigned requests on signed-only\n * operations will pass. Verify the implementation actually resolves the\n * operation name for every AdCP route you care to protect; a unit test\n * asserting `resolveOperation(req)` is non-undefined for sample signed\n * requests is the simplest guard.\n */\n resolveOperation: (req: ExpressLike) => string | undefined;\n /**\n * Override how the request's full URL is reconstructed. Use when the server\n * sits behind a TLS-terminating or path-rewriting load balancer, since\n * `req.protocol`/`req.get('host')` may not reflect what the signer signed.\n * Must return the exact URL the signer used (scheme + authority + path + query).\n */\n getUrl?: (req: ExpressLike) => string;\n}\n\ntype NextFn = (err?: unknown) => void;\n\nexport function createExpressVerifier(options: ExpressMiddlewareOptions) {\n // Instantiate defaults once at wire-up so every request on this middleware\n // shares the same replay/revocation state — lazy per-request construction\n // would defeat replay detection entirely.\n const replayStore = options.replayStore ?? new InMemoryReplayStore();\n const revocationStore = options.revocationStore ?? new InMemoryRevocationStore();\n\n return async function requestSignatureMiddleware(\n req: ExpressLike,\n res: { status: (code: number) => { set: (k: string, v: string) => { json: (body: unknown) => void } } },\n next: NextFn\n ): Promise<void> {\n try {\n const url = options.getUrl ? options.getUrl(req) : defaultUrl(req);\n const body = resolveRawBody(req);\n const requestLike: RequestLike = {\n method: req.method,\n url,\n headers: req.headers,\n body,\n };\n const result = await verifyRequestSignature(requestLike, {\n ...options,\n replayStore,\n revocationStore,\n operation: options.resolveOperation(req),\n });\n if (result.status === 'verified') {\n req.verifiedSigner = {\n keyid: result.keyid,\n agent_url: result.agent_url,\n verified_at: result.verified_at,\n };\n }\n next();\n } catch (err) {\n if (err instanceof RequestSignatureError) {\n // `failed_step` is informational per spec; keep it in server-side logs\n // rather than the 401 body so anonymous callers can't enumerate the\n // verifier pipeline. Do NOT call next() here — Express convention is\n // that a terminal response ends the middleware chain without\n // invoking downstream handlers. Promise-based wrappers that need to\n // await completion should race against the response's 'close' event\n // rather than the next-callback.\n res.status(401).set('WWW-Authenticate', `Signature error=\"${err.code}\"`).json({\n error: err.code,\n message: 'Request signature verification failed',\n });\n return;\n }\n next(err);\n }\n };\n}\n\n/** Methods that may carry a request body, so a missing `rawBody` is suspect. */\nconst BODY_BEARING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);\n\nfunction resolveRawBody(req: ExpressLike): string {\n // The canonical raw-body recipe (`express.json({ verify })`, and this SDK's\n // own `createExpressAdapter().rawBodyVerify`) may hand back either a string\n // or the raw Buffer, so accept both rather than 401-ing a correctly wired app.\n if (typeof req.rawBody === 'string') return req.rawBody;\n if (Buffer.isBuffer(req.rawBody)) return req.rawBody.toString('utf8');\n\n // Verifying against `''` when the request actually carried a body would\n // authenticate a surrogate: `validateCoveredComponents` derives `hasBody`\n // from this value (so the `content-type` requirement lapses) and the\n // `content-digest` check would describe the empty string, after which\n // `next()` hands a downstream parser's real body to the handler.\n //\n // So the test is for positive proof that NO body exists, not for evidence\n // that one does. Enumerating body signals cannot work: HTTP/2 forbids\n // `Transfer-Encoding` outright and makes `content-length` optional, so a\n // DATA-frame body carries neither header and would slip through a\n // signal-based check. Only a non-body-bearing method with an explicit\n // zero (or absent) length can honestly verify against `''`.\n if (BODY_BEARING_METHODS.has(req.method?.toUpperCase() ?? '') || declaresBody(req) || hasParsedBody(req)) {\n throw new RequestSignatureError(\n 'request_signature_header_malformed',\n 1,\n 'req.rawBody is required for signed requests that may carry a body. Capture it before any body ' +\n 'parser runs, e.g. `app.use(express.json({ verify: adapter.rawBodyVerify }))` using ' +\n \"`createExpressAdapter()`, or `app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } }))`. \" +\n 'The capture middleware must be mounted ahead of createExpressVerifier.'\n );\n }\n return '';\n}\n\n/**\n * A `Content-Length` that is present and non-zero, or unparseable. `00`, `+0`,\n * and `0.0` are all legitimately zero; anything `Number` cannot read is treated\n * as a body so the failure direction stays closed.\n */\nfunction declaresBody(req: ExpressLike): boolean {\n const length = getHeaderValue(req.headers, 'content-length');\n if (length !== undefined) {\n const parsed = Number(length);\n if (!Number.isFinite(parsed)) return true;\n if (parsed !== 0) return true;\n }\n return getHeaderValue(req.headers, 'transfer-encoding') !== undefined;\n}\n\n/** A body parser mounted ahead of this middleware already consumed the stream. */\nfunction hasParsedBody(req: ExpressLike): boolean {\n const body = req.body;\n if (body === undefined || body === null) return false;\n if (typeof body === 'string') return body.length > 0;\n if (Buffer.isBuffer(body)) return body.length > 0;\n // Only a genuinely empty plain object counts as \"no body\" — Express sets\n // `req.body = {}` for bodiless requests. Anything else with no own enumerable\n // keys (URLSearchParams, Map, a class instance) may still hold data.\n if (typeof body === 'object') {\n const proto = Object.getPrototypeOf(body);\n const isPlainObject = proto === Object.prototype || proto === null;\n if (!isPlainObject) return true;\n return Object.keys(body as Record<string, unknown>).length > 0;\n }\n return true;\n}\n\nfunction defaultUrl(req: ExpressLike): string {\n const proto = req.protocol ?? (typeof req.get === 'function' ? req.get('x-forwarded-proto') : undefined) ?? 'https';\n const host = typeof req.get === 'function' ? req.get('host') : undefined;\n if (!host) {\n throw new Error(\n 'Unable to derive request URL: missing Host header. Pass `getUrl` on createExpressVerifier to supply the canonical URL explicitly.'\n );\n }\n return `${proto}://${host}${req.originalUrl ?? req.url}`;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook-verifier.d.ts","sourceRoot":"","sources":["../../../src/lib/signing/webhook-verifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAA0D,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAK1G,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAC3C,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AACjE,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAG7E,eAAO,MAAM,mBAAmB,4BAA4B,CAAC;AAE7D;;;;;GAKG;AACH,eAAO,MAAM,4BAA4B,EAAE,aAAa,CAAC,MAAM,CAM9D,CAAC;AAEF,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,YAAY,CAAC;IACnB,WAAW,EAAE,WAAW,CAAC;IACzB,eAAe,EAAE,eAAe,CAAC;IACjC,mEAAmE;IACnE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CAC1D;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,mBAAmB,CAAC,CA2M9B;
|
|
1
|
+
{"version":3,"file":"webhook-verifier.d.ts","sourceRoot":"","sources":["../../../src/lib/signing/webhook-verifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAA0D,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAK1G,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAC3C,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,UAAU,CAAC;AACjE,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAG7E,eAAO,MAAM,mBAAmB,4BAA4B,CAAC;AAE7D;;;;;GAKG;AACH,eAAO,MAAM,4BAA4B,EAAE,aAAa,CAAC,MAAM,CAM9D,CAAC;AAEF,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,YAAY,CAAC;IACnB,WAAW,EAAE,WAAW,CAAC;IACzB,eAAe,EAAE,eAAe,CAAC;IACjC,mEAAmE;IACnE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CAC1D;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,mBAAmB,CAAC,CA2M9B;AAyHD;;;;;GAKG;AACH,MAAM,WAAW,4BAA6B,SAAQ,IAAI,CAAC,oBAAoB,EAAE,aAAa,GAAG,iBAAiB,CAAC;IACjH;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,4BAA4B,GACpC,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAOxD"}
|
|
@@ -245,10 +245,12 @@ function validateTargetUri(rawUrl) {
|
|
|
245
245
|
throw new import_errors.WebhookSignatureError("webhook_target_uri_malformed", 6, "@target-uri must not carry a fragment.");
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
|
+
const LOOPBACK_IPV4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
|
248
249
|
function isLoopbackHost(hostname) {
|
|
249
250
|
if (!hostname) return false;
|
|
250
|
-
const normalized = hostname.toLowerCase();
|
|
251
|
-
|
|
251
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, "");
|
|
252
|
+
if (normalized === "localhost" || normalized === "::1" || normalized === "[::1]") return true;
|
|
253
|
+
return LOOPBACK_IPV4.test(normalized);
|
|
252
254
|
}
|
|
253
255
|
function createWebhookVerifier(options) {
|
|
254
256
|
const replayStore = options.replayStore ?? new import_replay.InMemoryReplayStore();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/signing/webhook-verifier.ts"],"sourcesContent":["/**\n * RFC 9421 webhook-signing verifier (AdCP 3.0 webhook profile).\n *\n * Companion to `verifier.ts`, which verifies outbound request signatures.\n * This verifier runs on the receiving side of a publisher webhook: the\n * storyboard runner's receiver captures a POST and hands the request (headers\n * + raw body) here for signature validation.\n *\n * Distinct from request-signing:\n * - Tag: `adcp/webhook-signing/v1` (vs `adcp/request-signing/v1`).\n * - Key purpose: `adcp_use: \"webhook-signing\"` OR `\"request-signing\"` — a\n * signer may reuse its request-signing key for webhooks (see step 8).\n * - Covered components MUST include `@method`, `@target-uri`, `@authority`,\n * `content-type`, and `content-digest` — `content-digest` is unconditional\n * for webhooks (vs policy-driven on requests) because every webhook\n * carries a JSON body.\n * - JWKS resolved via the publisher's `brand.json` `agents[]` `jwks_uri`,\n * not the request-path capability document.\n *\n * Checklist steps below mirror the 14-step shape in\n * `docs/building/implementation/security.mdx#verifier-checklist-for-webhooks`\n * so failures can point at a specific spec clause. Numbers are 1-based to\n * match the security doc.\n */\n\nimport { buildSignatureBase, canonicalTargetUri, getHeaderValue, type RequestLike } from './canonicalize';\nimport { contentDigestMatches } from './content-digest';\nimport { RequestSignatureError, WebhookSignatureError } from './errors';\nimport { parseSignature, parseSignatureInput, type ParsedSignatureInput } from './parser';\nimport { jwkToPublicKey, verifySignature } from './crypto';\nimport type { JwksResolver } from './jwks';\nimport { InMemoryReplayStore, type ReplayStore } from './replay';\nimport { InMemoryRevocationStore, type RevocationStore } from './revocation';\nimport { ALLOWED_ALGS, CLOCK_SKEW_TOLERANCE_SECONDS, MAX_SIGNATURE_WINDOW_SECONDS } from './types';\n\nexport const WEBHOOK_SIGNING_TAG = 'adcp/webhook-signing/v1';\n\n/**\n * Covered-component minimum for webhooks. Unlike request-signing, every\n * webhook carries a body so `content-type` and `content-digest` are\n * unconditional — a webhook without `content-digest` coverage can't be\n * verified because the body has no anchor.\n */\nexport const WEBHOOK_MANDATORY_COMPONENTS: ReadonlyArray<string> = [\n '@method',\n '@target-uri',\n '@authority',\n 'content-type',\n 'content-digest',\n];\n\nexport interface VerifyWebhookOptions {\n jwks: JwksResolver;\n replayStore: ReplayStore;\n revocationStore: RevocationStore;\n /** Now in seconds since epoch. Defaults to `Date.now() / 1000`. */\n now?: () => number;\n /**\n * Optional tag override — spec currently defines exactly\n * `adcp/webhook-signing/v1`; the override lets test vectors pin a version.\n */\n requiredTag?: string;\n /** Optional reverse-lookup (kid → publisher URL) for result attribution. */\n agentUrlForKeyid?: (keyid: string) => string | undefined;\n}\n\nexport interface VerifyWebhookResult {\n status: 'verified';\n keyid: string;\n agent_url?: string;\n verified_at: number;\n}\n\n/**\n * Verify an inbound webhook's RFC 9421 signature.\n *\n * Checklist steps 1–13 plus sub-step 9a, matching the canonical numbering\n * in `security.mdx#verifier-checklist-for-webhooks` (and the `failed_step`\n * values on the conformance vectors at\n * `test-vectors/webhook-signing/negative/`). Throws\n * `WebhookSignatureError` on the first failed step.\n *\n * Ordering invariant: cheap invariant checks (tag, alg, window, components)\n * run before JWKS resolution (step 7); revocation (9) and rate-abuse (9a)\n * run before cryptographic verify (10) so an attacker can't amplify\n * Ed25519/ECDSA work. Replay insert (13) commits only after every earlier\n * step has passed — any signature failing at step 10 never consumes a cap\n * entry, so external traffic can't grow the per-keyid cache.\n */\nexport async function verifyWebhookSignature(\n request: RequestLike,\n options: VerifyWebhookOptions\n): Promise<VerifyWebhookResult> {\n const now = options.now ? options.now() : Math.floor(Date.now() / 1000);\n const requiredTag = options.requiredTag ?? WEBHOOK_SIGNING_TAG;\n\n // Step 1: both signature headers present AND parseable. The bound-pair\n // rule (`Signature` without `Signature-Input` or vice versa) rejects here.\n const sigInputHeader = getHeaderValue(request.headers, 'Signature-Input');\n const sigHeader = getHeaderValue(request.headers, 'Signature');\n if (!sigInputHeader || !sigHeader) {\n throw new WebhookSignatureError(\n 'webhook_signature_header_malformed',\n 1,\n 'Webhook is missing Signature or Signature-Input headers.'\n );\n }\n let parsedInput: ParsedSignatureInput;\n let parsedSig: ReturnType<typeof parseSignature>;\n try {\n parsedInput = parseSignatureInput(sigInputHeader);\n parsedSig = parseSignature(sigHeader, parsedInput.label);\n } catch (err) {\n throw new WebhookSignatureError(\n 'webhook_signature_header_malformed',\n 1,\n err instanceof Error ? err.message : String(err)\n );\n }\n\n // Step 2: required params present.\n requireParams(parsedInput);\n\n // Step 3: tag match.\n if (parsedInput.params.tag !== requiredTag) {\n throw new WebhookSignatureError(\n 'webhook_signature_tag_invalid',\n 3,\n `Signature tag must be \"${requiredTag}\"; got \"${parsedInput.params.tag}\".`\n );\n }\n\n // Step 4: alg allowlist.\n if (!ALLOWED_ALGS.has(parsedInput.params.alg)) {\n throw new WebhookSignatureError(\n 'webhook_signature_alg_not_allowed',\n 4,\n `Signature alg \"${parsedInput.params.alg}\" is not in the AdCP allowlist.`\n );\n }\n\n // Step 5: window valid.\n validateWindow(parsedInput.params.created, parsedInput.params.expires, now);\n\n // Step 6: covered components.\n validateCoveredComponents(parsedInput.components);\n\n // Step 6a: `@target-uri` syntactic validation. Runs before JWKS resolution\n // and crypto verify so a malformed URI short-circuits without JWKS\n // side-effects. Distinct from `header_malformed` (the Signature /\n // Signature-Input headers themselves) — this flags the covered URI value\n // that will be fed into the signature base. Non-parseable URLs, non-https\n // schemes, embedded userinfo, and fragments are all rejected.\n validateTargetUri(request.url);\n\n // Step 7: resolve keyid.\n const jwk = await options.jwks.resolve(parsedInput.params.keyid);\n if (!jwk) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_unknown',\n 7,\n `No JWK found for keyid \"${parsedInput.params.keyid}\".`\n );\n }\n if (jwk.kid !== parsedInput.params.keyid) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_unknown',\n 7,\n `JWKS resolver returned a JWK whose kid \"${jwk.kid}\" does not match requested keyid \"${parsedInput.params.keyid}\".`\n );\n }\n\n // Step 8: key purpose — webhooks are signed with a `request-signing` key.\n //\n // Webhooks carry no separate key purpose: the signer uses its\n // `adcp_use: \"request-signing\"` key (the deprecated `\"webhook-signing\"`\n // value is still accepted here for backward compatibility, pending removal — adcontextprotocol/adcp#5555).\n // This is safe because cross-protocol confusion is prevented by the\n // signature `tag` (step 3, `adcp/webhook-signing/v1`, part of the signed\n // base) and mandatory `content-digest` coverage (step 6) — not by the\n // key-purpose discriminator. A captured request signature\n // (`tag=adcp/request-signing/v1`) can never be replayed against this\n // verifier because step 3 rejects it. Webhook key isolation, when wanted, is\n // a second `request-signing` key under a distinct `kid` — not a distinct\n // `adcp_use`.\n //\n // All key-purpose failures use `webhook_signature_key_purpose_invalid`:\n // absent `adcp_use`, a missing `verify` key_op, or an `adcp_use` outside the\n // accepted set (e.g. `response-signing`, `governance-signing`). We do NOT\n // reuse `webhook_mode_mismatch` here — that code is reserved for the\n // HMAC-vs-9421 auth-mode selector mismatch, and overloading it would collapse\n // two distinct failure classes onto one stable code receivers branch on.\n if (\n jwk.adcp_use === undefined ||\n !jwk.key_ops?.includes('verify') ||\n (jwk.adcp_use !== 'request-signing' && jwk.adcp_use !== 'webhook-signing')\n ) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_purpose_invalid',\n 8,\n `JWK \"${jwk.kid}\" is not scoped for webhook delivery: adcp_use must be \"request-signing\" (or the deprecated \"webhook-signing\") with a \"verify\" key_op (got adcp_use=\"${jwk.adcp_use}\").`\n );\n }\n\n // Step 9: revocation. Shared store throws `request_signature_revocation_stale`\n // when its cached snapshot is past grace — re-map to the webhook taxonomy\n // so callers see a consistent error surface.\n try {\n if (await options.revocationStore.isRevoked(jwk.kid)) {\n throw new WebhookSignatureError('webhook_signature_key_revoked', 9, `JWK \"${jwk.kid}\" is revoked.`);\n }\n } catch (err) {\n if (err instanceof RequestSignatureError && err.code === 'request_signature_revocation_stale') {\n throw new WebhookSignatureError('webhook_signature_revocation_stale', 9, err.message);\n }\n throw err;\n }\n\n // Replay cache is scoped by `(keyid, @target-uri)` per adcp#2460 — a\n // webhook captured on one receiver path MUST NOT count against the replay\n // budget for a different path under the same keyid. Canonicalize once and\n // reuse for both pre-check and commit.\n const replayScope = canonicalTargetUri(request.url);\n\n // Step 9a: per-keyid rate abuse. Distinct code from step 12's replay —\n // cap exhaustion is a compromised-key / misconfig signal that SHOULD\n // alert operators, not \"same nonce twice.\"\n if (await options.replayStore.isCapHit(jwk.kid, replayScope, now)) {\n throw new WebhookSignatureError(\n 'webhook_signature_rate_abuse',\n 9,\n `Per-keyid replay cache cap exceeded for keyid=${jwk.kid}.`\n );\n }\n\n // Pre-check step 12's replay before crypto so a replayed nonce short-\n // circuits an expensive Ed25519/ECDSA verify.\n if (await options.replayStore.has(jwk.kid, replayScope, parsedInput.params.nonce, now)) {\n throw new WebhookSignatureError(\n 'webhook_signature_replayed',\n 12,\n `Replay of (keyid=${jwk.kid}, nonce=${parsedInput.params.nonce}) within signature window.`\n );\n }\n\n // Step 10: cryptographic verify.\n const base = buildSignatureBase(\n parsedInput.components,\n request,\n parsedInput.params,\n parsedInput.signatureParamsValue\n );\n const publicKey = jwkToPublicKey(jwk);\n const valid = verifySignature(parsedInput.params.alg, publicKey, Buffer.from(base, 'utf8'), parsedSig.bytes);\n if (!valid) {\n throw new WebhookSignatureError(\n 'webhook_signature_invalid',\n 10,\n 'Cryptographic verification of webhook signature base failed.'\n );\n }\n\n // Step 11: content-digest match.\n const digestHeader = getHeaderValue(request.headers, 'Content-Digest');\n if (!digestHeader || !contentDigestMatches(digestHeader, request.body ?? '')) {\n throw new WebhookSignatureError(\n 'webhook_signature_digest_mismatch',\n 11,\n 'Content-Digest header does not match recomputed body hash.'\n );\n }\n\n // Step 13: commit nonce. Insert AFTER every prior check passes — this is\n // the load-bearing invariant that keeps external traffic from growing the\n // cap (any signature failing at step 10 never consumes an entry).\n const remaining = parsedInput.params.expires - now + CLOCK_SKEW_TOLERANCE_SECONDS;\n const ttl = Math.max(remaining, MAX_SIGNATURE_WINDOW_SECONDS + CLOCK_SKEW_TOLERANCE_SECONDS);\n const insertResult = await options.replayStore.insert(jwk.kid, replayScope, parsedInput.params.nonce, ttl, now);\n if (insertResult === 'replayed') {\n throw new WebhookSignatureError(\n 'webhook_signature_replayed',\n 13,\n `Replay of (keyid=${jwk.kid}, nonce=${parsedInput.params.nonce}) within signature window.`\n );\n }\n if (insertResult === 'rate_abuse') {\n throw new WebhookSignatureError(\n 'webhook_signature_rate_abuse',\n 13,\n `Per-keyid replay cache cap exceeded on commit for keyid=${jwk.kid}.`\n );\n }\n\n const agent_url = options.agentUrlForKeyid?.(jwk.kid);\n return { status: 'verified', keyid: jwk.kid, ...(agent_url !== undefined && { agent_url }), verified_at: now };\n}\n\nfunction requireParams(parsed: ParsedSignatureInput): void {\n const required: Array<keyof ParsedSignatureInput['params']> = ['created', 'expires', 'nonce', 'keyid', 'alg', 'tag'];\n const missing = required.filter(k => parsed.params[k] === undefined);\n if (missing.length) {\n throw new WebhookSignatureError(\n 'webhook_signature_params_incomplete',\n 2,\n `Signature-Input missing required parameter(s): ${missing.join(', ')}.`\n );\n }\n}\n\nfunction validateWindow(created: number, expires: number, now: number): void {\n // Spec folds every window-level failure — expired, negative window,\n // over-long window, created-in-future — into webhook_signature_window_invalid.\n // The error message carries the specific subtype for diagnostics.\n if (expires <= created) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n 'Signature expires must be strictly greater than created.'\n );\n }\n if (expires - created > MAX_SIGNATURE_WINDOW_SECONDS) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n `Signature window exceeds ${MAX_SIGNATURE_WINDOW_SECONDS}s maximum.`\n );\n }\n if (now < created - CLOCK_SKEW_TOLERANCE_SECONDS) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n 'Signature created is in the future beyond skew tolerance.'\n );\n }\n if (now > expires + CLOCK_SKEW_TOLERANCE_SECONDS) {\n throw new WebhookSignatureError('webhook_signature_window_invalid', 5, 'Signature is expired.');\n }\n}\n\nfunction validateCoveredComponents(components: string[]): void {\n for (const mandatory of WEBHOOK_MANDATORY_COMPONENTS) {\n if (!components.includes(mandatory)) {\n throw new WebhookSignatureError(\n 'webhook_signature_components_incomplete',\n 6,\n `Covered components must include \"${mandatory}\".`\n );\n }\n }\n}\n\n/**\n * Syntactic validation of the `@target-uri` value before signature\n * computation. Four failure modes:\n * - URL doesn't parse at all.\n * - Scheme is not https (webhooks MUST be TLS-terminated; loopback\n * addresses are exempt so `loopback_mock` test receivers — the\n * storyboard runner's default — can operate over plain http).\n * - Authority contains userinfo — credentials don't belong in a signed URI.\n * - URL carries a fragment — fragments are client-side and never transmitted.\n *\n * Each throws `webhook_target_uri_malformed` with the failure reason in the\n * message. Distinct from `webhook_signature_header_malformed`, which flags\n * the Signature / Signature-Input headers themselves.\n */\nfunction validateTargetUri(rawUrl: string): void {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n throw new WebhookSignatureError(\n 'webhook_target_uri_malformed',\n 6,\n `@target-uri \"${rawUrl}\" is not a parseable URL.`\n );\n }\n if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname)) {\n throw new WebhookSignatureError(\n 'webhook_target_uri_malformed',\n 6,\n `@target-uri must use https; got \"${url.protocol}\" in \"${rawUrl}\".`\n );\n }\n if (url.username || url.password) {\n throw new WebhookSignatureError('webhook_target_uri_malformed', 6, '@target-uri must not embed userinfo.');\n }\n if (url.hash) {\n throw new WebhookSignatureError('webhook_target_uri_malformed', 6, '@target-uri must not carry a fragment.');\n }\n}\n\n/**\n * Loopback host exemption for the https-only rule. The storyboard runner's\n * `loopback_mock` webhook receiver binds to 127.0.0.1 (or the IPv6 [::1])\n * and publishers emit `http://127.0.0.1:<port>/…` URLs for delivery; the\n * signature covers `@target-uri` byte-for-byte, so the verifier sees the\n * http scheme. Production traffic never reaches loopback, so this exemption\n * can't be used to bypass TLS for real webhooks.\n */\nfunction isLoopbackHost(hostname: string): boolean {\n if (!hostname) return false;\n const normalized = hostname.toLowerCase();\n return normalized === 'localhost' || normalized === '::1' || normalized.startsWith('127.');\n}\n\n/**\n * Options for {@link createWebhookVerifier}. Identical to\n * {@link VerifyWebhookOptions} except `replayStore` and `revocationStore` are\n * optional — the factory defaults them once at creation time so replay state\n * is shared across every request the returned verifier handles.\n */\nexport interface CreateWebhookVerifierOptions extends Omit<VerifyWebhookOptions, 'replayStore' | 'revocationStore'> {\n /**\n * Stores `(keyid, scope, nonce)` tuples for replay detection.\n * Defaults to a fresh {@link InMemoryReplayStore} — suitable for single-process\n * deployments only. **Multi-replica deployments MUST pass an explicit shared\n * store** (Redis, Postgres, etc.) — the default in-memory store does not\n * survive process boundaries, so a signature accepted on replica A is\n * invisible to replica B and can be replayed there within the signature window.\n */\n replayStore?: ReplayStore;\n /**\n * Consulted for revoked `kid` before accepting a signature.\n * Defaults to a fresh {@link InMemoryRevocationStore}, which starts empty\n * and does not poll for updates. The default is sufficient when you don't\n * revoke keys at runtime; when you do, pass a store backed by your secrets\n * manager or admin tooling so revocations take effect without redeployment.\n */\n revocationStore?: RevocationStore;\n}\n\n/**\n * Create a bound webhook-signature verifier with shared replay and revocation\n * stores. Mirrors {@link createExpressVerifier} for the webhook profile.\n *\n * The returned function is the per-request entry point: call it with each\n * inbound webhook's {@link RequestLike} representation.\n *\n * **Why a factory?** Replay detection requires the same store instance to be\n * consulted across every request. Constructing stores inside a per-request\n * call would silently defeat replay dedup — each call would start with an\n * empty store and always accept the nonce. The factory pattern captures the\n * store instances in closure scope at wire-up time, guaranteeing they are\n * shared across calls for the lifetime of the verifier.\n *\n * **Multi-replica deployments MUST pass an explicit `replayStore`** backed by\n * a shared persistence layer (Redis, Postgres, etc.) — the default\n * `InMemoryReplayStore` does not survive process boundaries. A nonce accepted\n * on replica A is invisible to replica B; replaying the webhook to B succeeds\n * silently within the signature window.\n */\nexport function createWebhookVerifier(\n options: CreateWebhookVerifierOptions\n): (request: RequestLike) => Promise<VerifyWebhookResult> {\n // Instantiate defaults once at creation time so every request handled by\n // this verifier shares the same replay / revocation state. Per-request\n // construction would defeat replay detection entirely.\n const replayStore = options.replayStore ?? new InMemoryReplayStore();\n const revocationStore = options.revocationStore ?? new InMemoryRevocationStore();\n return (request: RequestLike) => verifyWebhookSignature(request, { ...options, replayStore, revocationStore });\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/signing/webhook-verifier.ts"],"sourcesContent":["/**\n * RFC 9421 webhook-signing verifier (AdCP 3.0 webhook profile).\n *\n * Companion to `verifier.ts`, which verifies outbound request signatures.\n * This verifier runs on the receiving side of a publisher webhook: the\n * storyboard runner's receiver captures a POST and hands the request (headers\n * + raw body) here for signature validation.\n *\n * Distinct from request-signing:\n * - Tag: `adcp/webhook-signing/v1` (vs `adcp/request-signing/v1`).\n * - Key purpose: `adcp_use: \"webhook-signing\"` OR `\"request-signing\"` — a\n * signer may reuse its request-signing key for webhooks (see step 8).\n * - Covered components MUST include `@method`, `@target-uri`, `@authority`,\n * `content-type`, and `content-digest` — `content-digest` is unconditional\n * for webhooks (vs policy-driven on requests) because every webhook\n * carries a JSON body.\n * - JWKS resolved via the publisher's `brand.json` `agents[]` `jwks_uri`,\n * not the request-path capability document.\n *\n * Checklist steps below mirror the 14-step shape in\n * `docs/building/implementation/security.mdx#verifier-checklist-for-webhooks`\n * so failures can point at a specific spec clause. Numbers are 1-based to\n * match the security doc.\n */\n\nimport { buildSignatureBase, canonicalTargetUri, getHeaderValue, type RequestLike } from './canonicalize';\nimport { contentDigestMatches } from './content-digest';\nimport { RequestSignatureError, WebhookSignatureError } from './errors';\nimport { parseSignature, parseSignatureInput, type ParsedSignatureInput } from './parser';\nimport { jwkToPublicKey, verifySignature } from './crypto';\nimport type { JwksResolver } from './jwks';\nimport { InMemoryReplayStore, type ReplayStore } from './replay';\nimport { InMemoryRevocationStore, type RevocationStore } from './revocation';\nimport { ALLOWED_ALGS, CLOCK_SKEW_TOLERANCE_SECONDS, MAX_SIGNATURE_WINDOW_SECONDS } from './types';\n\nexport const WEBHOOK_SIGNING_TAG = 'adcp/webhook-signing/v1';\n\n/**\n * Covered-component minimum for webhooks. Unlike request-signing, every\n * webhook carries a body so `content-type` and `content-digest` are\n * unconditional — a webhook without `content-digest` coverage can't be\n * verified because the body has no anchor.\n */\nexport const WEBHOOK_MANDATORY_COMPONENTS: ReadonlyArray<string> = [\n '@method',\n '@target-uri',\n '@authority',\n 'content-type',\n 'content-digest',\n];\n\nexport interface VerifyWebhookOptions {\n jwks: JwksResolver;\n replayStore: ReplayStore;\n revocationStore: RevocationStore;\n /** Now in seconds since epoch. Defaults to `Date.now() / 1000`. */\n now?: () => number;\n /**\n * Optional tag override — spec currently defines exactly\n * `adcp/webhook-signing/v1`; the override lets test vectors pin a version.\n */\n requiredTag?: string;\n /** Optional reverse-lookup (kid → publisher URL) for result attribution. */\n agentUrlForKeyid?: (keyid: string) => string | undefined;\n}\n\nexport interface VerifyWebhookResult {\n status: 'verified';\n keyid: string;\n agent_url?: string;\n verified_at: number;\n}\n\n/**\n * Verify an inbound webhook's RFC 9421 signature.\n *\n * Checklist steps 1–13 plus sub-step 9a, matching the canonical numbering\n * in `security.mdx#verifier-checklist-for-webhooks` (and the `failed_step`\n * values on the conformance vectors at\n * `test-vectors/webhook-signing/negative/`). Throws\n * `WebhookSignatureError` on the first failed step.\n *\n * Ordering invariant: cheap invariant checks (tag, alg, window, components)\n * run before JWKS resolution (step 7); revocation (9) and rate-abuse (9a)\n * run before cryptographic verify (10) so an attacker can't amplify\n * Ed25519/ECDSA work. Replay insert (13) commits only after every earlier\n * step has passed — any signature failing at step 10 never consumes a cap\n * entry, so external traffic can't grow the per-keyid cache.\n */\nexport async function verifyWebhookSignature(\n request: RequestLike,\n options: VerifyWebhookOptions\n): Promise<VerifyWebhookResult> {\n const now = options.now ? options.now() : Math.floor(Date.now() / 1000);\n const requiredTag = options.requiredTag ?? WEBHOOK_SIGNING_TAG;\n\n // Step 1: both signature headers present AND parseable. The bound-pair\n // rule (`Signature` without `Signature-Input` or vice versa) rejects here.\n const sigInputHeader = getHeaderValue(request.headers, 'Signature-Input');\n const sigHeader = getHeaderValue(request.headers, 'Signature');\n if (!sigInputHeader || !sigHeader) {\n throw new WebhookSignatureError(\n 'webhook_signature_header_malformed',\n 1,\n 'Webhook is missing Signature or Signature-Input headers.'\n );\n }\n let parsedInput: ParsedSignatureInput;\n let parsedSig: ReturnType<typeof parseSignature>;\n try {\n parsedInput = parseSignatureInput(sigInputHeader);\n parsedSig = parseSignature(sigHeader, parsedInput.label);\n } catch (err) {\n throw new WebhookSignatureError(\n 'webhook_signature_header_malformed',\n 1,\n err instanceof Error ? err.message : String(err)\n );\n }\n\n // Step 2: required params present.\n requireParams(parsedInput);\n\n // Step 3: tag match.\n if (parsedInput.params.tag !== requiredTag) {\n throw new WebhookSignatureError(\n 'webhook_signature_tag_invalid',\n 3,\n `Signature tag must be \"${requiredTag}\"; got \"${parsedInput.params.tag}\".`\n );\n }\n\n // Step 4: alg allowlist.\n if (!ALLOWED_ALGS.has(parsedInput.params.alg)) {\n throw new WebhookSignatureError(\n 'webhook_signature_alg_not_allowed',\n 4,\n `Signature alg \"${parsedInput.params.alg}\" is not in the AdCP allowlist.`\n );\n }\n\n // Step 5: window valid.\n validateWindow(parsedInput.params.created, parsedInput.params.expires, now);\n\n // Step 6: covered components.\n validateCoveredComponents(parsedInput.components);\n\n // Step 6a: `@target-uri` syntactic validation. Runs before JWKS resolution\n // and crypto verify so a malformed URI short-circuits without JWKS\n // side-effects. Distinct from `header_malformed` (the Signature /\n // Signature-Input headers themselves) — this flags the covered URI value\n // that will be fed into the signature base. Non-parseable URLs, non-https\n // schemes, embedded userinfo, and fragments are all rejected.\n validateTargetUri(request.url);\n\n // Step 7: resolve keyid.\n const jwk = await options.jwks.resolve(parsedInput.params.keyid);\n if (!jwk) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_unknown',\n 7,\n `No JWK found for keyid \"${parsedInput.params.keyid}\".`\n );\n }\n if (jwk.kid !== parsedInput.params.keyid) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_unknown',\n 7,\n `JWKS resolver returned a JWK whose kid \"${jwk.kid}\" does not match requested keyid \"${parsedInput.params.keyid}\".`\n );\n }\n\n // Step 8: key purpose — webhooks are signed with a `request-signing` key.\n //\n // Webhooks carry no separate key purpose: the signer uses its\n // `adcp_use: \"request-signing\"` key (the deprecated `\"webhook-signing\"`\n // value is still accepted here for backward compatibility, pending removal — adcontextprotocol/adcp#5555).\n // This is safe because cross-protocol confusion is prevented by the\n // signature `tag` (step 3, `adcp/webhook-signing/v1`, part of the signed\n // base) and mandatory `content-digest` coverage (step 6) — not by the\n // key-purpose discriminator. A captured request signature\n // (`tag=adcp/request-signing/v1`) can never be replayed against this\n // verifier because step 3 rejects it. Webhook key isolation, when wanted, is\n // a second `request-signing` key under a distinct `kid` — not a distinct\n // `adcp_use`.\n //\n // All key-purpose failures use `webhook_signature_key_purpose_invalid`:\n // absent `adcp_use`, a missing `verify` key_op, or an `adcp_use` outside the\n // accepted set (e.g. `response-signing`, `governance-signing`). We do NOT\n // reuse `webhook_mode_mismatch` here — that code is reserved for the\n // HMAC-vs-9421 auth-mode selector mismatch, and overloading it would collapse\n // two distinct failure classes onto one stable code receivers branch on.\n if (\n jwk.adcp_use === undefined ||\n !jwk.key_ops?.includes('verify') ||\n (jwk.adcp_use !== 'request-signing' && jwk.adcp_use !== 'webhook-signing')\n ) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_purpose_invalid',\n 8,\n `JWK \"${jwk.kid}\" is not scoped for webhook delivery: adcp_use must be \"request-signing\" (or the deprecated \"webhook-signing\") with a \"verify\" key_op (got adcp_use=\"${jwk.adcp_use}\").`\n );\n }\n\n // Step 9: revocation. Shared store throws `request_signature_revocation_stale`\n // when its cached snapshot is past grace — re-map to the webhook taxonomy\n // so callers see a consistent error surface.\n try {\n if (await options.revocationStore.isRevoked(jwk.kid)) {\n throw new WebhookSignatureError('webhook_signature_key_revoked', 9, `JWK \"${jwk.kid}\" is revoked.`);\n }\n } catch (err) {\n if (err instanceof RequestSignatureError && err.code === 'request_signature_revocation_stale') {\n throw new WebhookSignatureError('webhook_signature_revocation_stale', 9, err.message);\n }\n throw err;\n }\n\n // Replay cache is scoped by `(keyid, @target-uri)` per adcp#2460 — a\n // webhook captured on one receiver path MUST NOT count against the replay\n // budget for a different path under the same keyid. Canonicalize once and\n // reuse for both pre-check and commit.\n const replayScope = canonicalTargetUri(request.url);\n\n // Step 9a: per-keyid rate abuse. Distinct code from step 12's replay —\n // cap exhaustion is a compromised-key / misconfig signal that SHOULD\n // alert operators, not \"same nonce twice.\"\n if (await options.replayStore.isCapHit(jwk.kid, replayScope, now)) {\n throw new WebhookSignatureError(\n 'webhook_signature_rate_abuse',\n 9,\n `Per-keyid replay cache cap exceeded for keyid=${jwk.kid}.`\n );\n }\n\n // Pre-check step 12's replay before crypto so a replayed nonce short-\n // circuits an expensive Ed25519/ECDSA verify.\n if (await options.replayStore.has(jwk.kid, replayScope, parsedInput.params.nonce, now)) {\n throw new WebhookSignatureError(\n 'webhook_signature_replayed',\n 12,\n `Replay of (keyid=${jwk.kid}, nonce=${parsedInput.params.nonce}) within signature window.`\n );\n }\n\n // Step 10: cryptographic verify.\n const base = buildSignatureBase(\n parsedInput.components,\n request,\n parsedInput.params,\n parsedInput.signatureParamsValue\n );\n const publicKey = jwkToPublicKey(jwk);\n const valid = verifySignature(parsedInput.params.alg, publicKey, Buffer.from(base, 'utf8'), parsedSig.bytes);\n if (!valid) {\n throw new WebhookSignatureError(\n 'webhook_signature_invalid',\n 10,\n 'Cryptographic verification of webhook signature base failed.'\n );\n }\n\n // Step 11: content-digest match.\n const digestHeader = getHeaderValue(request.headers, 'Content-Digest');\n if (!digestHeader || !contentDigestMatches(digestHeader, request.body ?? '')) {\n throw new WebhookSignatureError(\n 'webhook_signature_digest_mismatch',\n 11,\n 'Content-Digest header does not match recomputed body hash.'\n );\n }\n\n // Step 13: commit nonce. Insert AFTER every prior check passes — this is\n // the load-bearing invariant that keeps external traffic from growing the\n // cap (any signature failing at step 10 never consumes an entry).\n const remaining = parsedInput.params.expires - now + CLOCK_SKEW_TOLERANCE_SECONDS;\n const ttl = Math.max(remaining, MAX_SIGNATURE_WINDOW_SECONDS + CLOCK_SKEW_TOLERANCE_SECONDS);\n const insertResult = await options.replayStore.insert(jwk.kid, replayScope, parsedInput.params.nonce, ttl, now);\n if (insertResult === 'replayed') {\n throw new WebhookSignatureError(\n 'webhook_signature_replayed',\n 13,\n `Replay of (keyid=${jwk.kid}, nonce=${parsedInput.params.nonce}) within signature window.`\n );\n }\n if (insertResult === 'rate_abuse') {\n throw new WebhookSignatureError(\n 'webhook_signature_rate_abuse',\n 13,\n `Per-keyid replay cache cap exceeded on commit for keyid=${jwk.kid}.`\n );\n }\n\n const agent_url = options.agentUrlForKeyid?.(jwk.kid);\n return { status: 'verified', keyid: jwk.kid, ...(agent_url !== undefined && { agent_url }), verified_at: now };\n}\n\nfunction requireParams(parsed: ParsedSignatureInput): void {\n const required: Array<keyof ParsedSignatureInput['params']> = ['created', 'expires', 'nonce', 'keyid', 'alg', 'tag'];\n const missing = required.filter(k => parsed.params[k] === undefined);\n if (missing.length) {\n throw new WebhookSignatureError(\n 'webhook_signature_params_incomplete',\n 2,\n `Signature-Input missing required parameter(s): ${missing.join(', ')}.`\n );\n }\n}\n\nfunction validateWindow(created: number, expires: number, now: number): void {\n // Spec folds every window-level failure — expired, negative window,\n // over-long window, created-in-future — into webhook_signature_window_invalid.\n // The error message carries the specific subtype for diagnostics.\n if (expires <= created) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n 'Signature expires must be strictly greater than created.'\n );\n }\n if (expires - created > MAX_SIGNATURE_WINDOW_SECONDS) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n `Signature window exceeds ${MAX_SIGNATURE_WINDOW_SECONDS}s maximum.`\n );\n }\n if (now < created - CLOCK_SKEW_TOLERANCE_SECONDS) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n 'Signature created is in the future beyond skew tolerance.'\n );\n }\n if (now > expires + CLOCK_SKEW_TOLERANCE_SECONDS) {\n throw new WebhookSignatureError('webhook_signature_window_invalid', 5, 'Signature is expired.');\n }\n}\n\nfunction validateCoveredComponents(components: string[]): void {\n for (const mandatory of WEBHOOK_MANDATORY_COMPONENTS) {\n if (!components.includes(mandatory)) {\n throw new WebhookSignatureError(\n 'webhook_signature_components_incomplete',\n 6,\n `Covered components must include \"${mandatory}\".`\n );\n }\n }\n}\n\n/**\n * Syntactic validation of the `@target-uri` value before signature\n * computation. Four failure modes:\n * - URL doesn't parse at all.\n * - Scheme is not https (webhooks MUST be TLS-terminated; loopback\n * addresses are exempt so `loopback_mock` test receivers — the\n * storyboard runner's default — can operate over plain http).\n * - Authority contains userinfo — credentials don't belong in a signed URI.\n * - URL carries a fragment — fragments are client-side and never transmitted.\n *\n * Each throws `webhook_target_uri_malformed` with the failure reason in the\n * message. Distinct from `webhook_signature_header_malformed`, which flags\n * the Signature / Signature-Input headers themselves.\n */\nfunction validateTargetUri(rawUrl: string): void {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n throw new WebhookSignatureError(\n 'webhook_target_uri_malformed',\n 6,\n `@target-uri \"${rawUrl}\" is not a parseable URL.`\n );\n }\n if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname)) {\n throw new WebhookSignatureError(\n 'webhook_target_uri_malformed',\n 6,\n `@target-uri must use https; got \"${url.protocol}\" in \"${rawUrl}\".`\n );\n }\n if (url.username || url.password) {\n throw new WebhookSignatureError('webhook_target_uri_malformed', 6, '@target-uri must not embed userinfo.');\n }\n if (url.hash) {\n throw new WebhookSignatureError('webhook_target_uri_malformed', 6, '@target-uri must not carry a fragment.');\n }\n}\n\n/**\n * Loopback host exemption for the https-only rule. The storyboard runner's\n * `loopback_mock` webhook receiver binds to 127.0.0.1 (or the IPv6 [::1])\n * and publishers emit `http://127.0.0.1:<port>/…` URLs for delivery; the\n * signature covers `@target-uri` byte-for-byte, so the verifier sees the\n * http scheme.\n *\n * The exemption is restricted to IPv4 literals in 127.0.0.0/8, the IPv6\n * loopback, and the literal name `localhost`. A prefix test would also match\n * registered names like `127.example.com`, which resolve to arbitrary public\n * addresses and would let a real webhook drop TLS.\n */\n// Only reached with an already-parsed `URL.hostname`, so WHATWG has normalized\n// `127.1` / `0x7f000001` to dotted-quad form and rejected out-of-range octets\n// before this sees them. No octet-range check is needed (or reachable).\nconst LOOPBACK_IPV4 = /^127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/;\n\nfunction isLoopbackHost(hostname: string): boolean {\n if (!hostname) return false;\n // Node's `URL.hostname` keeps IPv6 literals bracketed; a FQDN keeps its dot.\n const normalized = hostname.toLowerCase().replace(/\\.$/, '');\n if (normalized === 'localhost' || normalized === '::1' || normalized === '[::1]') return true;\n return LOOPBACK_IPV4.test(normalized);\n}\n\n/**\n * Options for {@link createWebhookVerifier}. Identical to\n * {@link VerifyWebhookOptions} except `replayStore` and `revocationStore` are\n * optional — the factory defaults them once at creation time so replay state\n * is shared across every request the returned verifier handles.\n */\nexport interface CreateWebhookVerifierOptions extends Omit<VerifyWebhookOptions, 'replayStore' | 'revocationStore'> {\n /**\n * Stores `(keyid, scope, nonce)` tuples for replay detection.\n * Defaults to a fresh {@link InMemoryReplayStore} — suitable for single-process\n * deployments only. **Multi-replica deployments MUST pass an explicit shared\n * store** (Redis, Postgres, etc.) — the default in-memory store does not\n * survive process boundaries, so a signature accepted on replica A is\n * invisible to replica B and can be replayed there within the signature window.\n */\n replayStore?: ReplayStore;\n /**\n * Consulted for revoked `kid` before accepting a signature.\n * Defaults to a fresh {@link InMemoryRevocationStore}, which starts empty\n * and does not poll for updates. The default is sufficient when you don't\n * revoke keys at runtime; when you do, pass a store backed by your secrets\n * manager or admin tooling so revocations take effect without redeployment.\n */\n revocationStore?: RevocationStore;\n}\n\n/**\n * Create a bound webhook-signature verifier with shared replay and revocation\n * stores. Mirrors {@link createExpressVerifier} for the webhook profile.\n *\n * The returned function is the per-request entry point: call it with each\n * inbound webhook's {@link RequestLike} representation.\n *\n * **Why a factory?** Replay detection requires the same store instance to be\n * consulted across every request. Constructing stores inside a per-request\n * call would silently defeat replay dedup — each call would start with an\n * empty store and always accept the nonce. The factory pattern captures the\n * store instances in closure scope at wire-up time, guaranteeing they are\n * shared across calls for the lifetime of the verifier.\n *\n * **Multi-replica deployments MUST pass an explicit `replayStore`** backed by\n * a shared persistence layer (Redis, Postgres, etc.) — the default\n * `InMemoryReplayStore` does not survive process boundaries. A nonce accepted\n * on replica A is invisible to replica B; replaying the webhook to B succeeds\n * silently within the signature window.\n */\nexport function createWebhookVerifier(\n options: CreateWebhookVerifierOptions\n): (request: RequestLike) => Promise<VerifyWebhookResult> {\n // Instantiate defaults once at creation time so every request handled by\n // this verifier shares the same replay / revocation state. Per-request\n // construction would defeat replay detection entirely.\n const replayStore = options.replayStore ?? new InMemoryReplayStore();\n const revocationStore = options.revocationStore ?? new InMemoryRevocationStore();\n return (request: RequestLike) => verifyWebhookSignature(request, { ...options, replayStore, revocationStore });\n}\n"],"mappings":"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","names":[]}
|
|
@@ -219,10 +219,12 @@ function validateTargetUri(rawUrl) {
|
|
|
219
219
|
throw new WebhookSignatureError("webhook_target_uri_malformed", 6, "@target-uri must not carry a fragment.");
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
|
+
const LOOPBACK_IPV4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
|
222
223
|
function isLoopbackHost(hostname) {
|
|
223
224
|
if (!hostname) return false;
|
|
224
|
-
const normalized = hostname.toLowerCase();
|
|
225
|
-
|
|
225
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, "");
|
|
226
|
+
if (normalized === "localhost" || normalized === "::1" || normalized === "[::1]") return true;
|
|
227
|
+
return LOOPBACK_IPV4.test(normalized);
|
|
226
228
|
}
|
|
227
229
|
function createWebhookVerifier(options) {
|
|
228
230
|
const replayStore = options.replayStore ?? new InMemoryReplayStore();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/signing/webhook-verifier.ts"],"sourcesContent":["/**\n * RFC 9421 webhook-signing verifier (AdCP 3.0 webhook profile).\n *\n * Companion to `verifier.ts`, which verifies outbound request signatures.\n * This verifier runs on the receiving side of a publisher webhook: the\n * storyboard runner's receiver captures a POST and hands the request (headers\n * + raw body) here for signature validation.\n *\n * Distinct from request-signing:\n * - Tag: `adcp/webhook-signing/v1` (vs `adcp/request-signing/v1`).\n * - Key purpose: `adcp_use: \"webhook-signing\"` OR `\"request-signing\"` — a\n * signer may reuse its request-signing key for webhooks (see step 8).\n * - Covered components MUST include `@method`, `@target-uri`, `@authority`,\n * `content-type`, and `content-digest` — `content-digest` is unconditional\n * for webhooks (vs policy-driven on requests) because every webhook\n * carries a JSON body.\n * - JWKS resolved via the publisher's `brand.json` `agents[]` `jwks_uri`,\n * not the request-path capability document.\n *\n * Checklist steps below mirror the 14-step shape in\n * `docs/building/implementation/security.mdx#verifier-checklist-for-webhooks`\n * so failures can point at a specific spec clause. Numbers are 1-based to\n * match the security doc.\n */\n\nimport { buildSignatureBase, canonicalTargetUri, getHeaderValue, type RequestLike } from './canonicalize';\nimport { contentDigestMatches } from './content-digest';\nimport { RequestSignatureError, WebhookSignatureError } from './errors';\nimport { parseSignature, parseSignatureInput, type ParsedSignatureInput } from './parser';\nimport { jwkToPublicKey, verifySignature } from './crypto';\nimport type { JwksResolver } from './jwks';\nimport { InMemoryReplayStore, type ReplayStore } from './replay';\nimport { InMemoryRevocationStore, type RevocationStore } from './revocation';\nimport { ALLOWED_ALGS, CLOCK_SKEW_TOLERANCE_SECONDS, MAX_SIGNATURE_WINDOW_SECONDS } from './types';\n\nexport const WEBHOOK_SIGNING_TAG = 'adcp/webhook-signing/v1';\n\n/**\n * Covered-component minimum for webhooks. Unlike request-signing, every\n * webhook carries a body so `content-type` and `content-digest` are\n * unconditional — a webhook without `content-digest` coverage can't be\n * verified because the body has no anchor.\n */\nexport const WEBHOOK_MANDATORY_COMPONENTS: ReadonlyArray<string> = [\n '@method',\n '@target-uri',\n '@authority',\n 'content-type',\n 'content-digest',\n];\n\nexport interface VerifyWebhookOptions {\n jwks: JwksResolver;\n replayStore: ReplayStore;\n revocationStore: RevocationStore;\n /** Now in seconds since epoch. Defaults to `Date.now() / 1000`. */\n now?: () => number;\n /**\n * Optional tag override — spec currently defines exactly\n * `adcp/webhook-signing/v1`; the override lets test vectors pin a version.\n */\n requiredTag?: string;\n /** Optional reverse-lookup (kid → publisher URL) for result attribution. */\n agentUrlForKeyid?: (keyid: string) => string | undefined;\n}\n\nexport interface VerifyWebhookResult {\n status: 'verified';\n keyid: string;\n agent_url?: string;\n verified_at: number;\n}\n\n/**\n * Verify an inbound webhook's RFC 9421 signature.\n *\n * Checklist steps 1–13 plus sub-step 9a, matching the canonical numbering\n * in `security.mdx#verifier-checklist-for-webhooks` (and the `failed_step`\n * values on the conformance vectors at\n * `test-vectors/webhook-signing/negative/`). Throws\n * `WebhookSignatureError` on the first failed step.\n *\n * Ordering invariant: cheap invariant checks (tag, alg, window, components)\n * run before JWKS resolution (step 7); revocation (9) and rate-abuse (9a)\n * run before cryptographic verify (10) so an attacker can't amplify\n * Ed25519/ECDSA work. Replay insert (13) commits only after every earlier\n * step has passed — any signature failing at step 10 never consumes a cap\n * entry, so external traffic can't grow the per-keyid cache.\n */\nexport async function verifyWebhookSignature(\n request: RequestLike,\n options: VerifyWebhookOptions\n): Promise<VerifyWebhookResult> {\n const now = options.now ? options.now() : Math.floor(Date.now() / 1000);\n const requiredTag = options.requiredTag ?? WEBHOOK_SIGNING_TAG;\n\n // Step 1: both signature headers present AND parseable. The bound-pair\n // rule (`Signature` without `Signature-Input` or vice versa) rejects here.\n const sigInputHeader = getHeaderValue(request.headers, 'Signature-Input');\n const sigHeader = getHeaderValue(request.headers, 'Signature');\n if (!sigInputHeader || !sigHeader) {\n throw new WebhookSignatureError(\n 'webhook_signature_header_malformed',\n 1,\n 'Webhook is missing Signature or Signature-Input headers.'\n );\n }\n let parsedInput: ParsedSignatureInput;\n let parsedSig: ReturnType<typeof parseSignature>;\n try {\n parsedInput = parseSignatureInput(sigInputHeader);\n parsedSig = parseSignature(sigHeader, parsedInput.label);\n } catch (err) {\n throw new WebhookSignatureError(\n 'webhook_signature_header_malformed',\n 1,\n err instanceof Error ? err.message : String(err)\n );\n }\n\n // Step 2: required params present.\n requireParams(parsedInput);\n\n // Step 3: tag match.\n if (parsedInput.params.tag !== requiredTag) {\n throw new WebhookSignatureError(\n 'webhook_signature_tag_invalid',\n 3,\n `Signature tag must be \"${requiredTag}\"; got \"${parsedInput.params.tag}\".`\n );\n }\n\n // Step 4: alg allowlist.\n if (!ALLOWED_ALGS.has(parsedInput.params.alg)) {\n throw new WebhookSignatureError(\n 'webhook_signature_alg_not_allowed',\n 4,\n `Signature alg \"${parsedInput.params.alg}\" is not in the AdCP allowlist.`\n );\n }\n\n // Step 5: window valid.\n validateWindow(parsedInput.params.created, parsedInput.params.expires, now);\n\n // Step 6: covered components.\n validateCoveredComponents(parsedInput.components);\n\n // Step 6a: `@target-uri` syntactic validation. Runs before JWKS resolution\n // and crypto verify so a malformed URI short-circuits without JWKS\n // side-effects. Distinct from `header_malformed` (the Signature /\n // Signature-Input headers themselves) — this flags the covered URI value\n // that will be fed into the signature base. Non-parseable URLs, non-https\n // schemes, embedded userinfo, and fragments are all rejected.\n validateTargetUri(request.url);\n\n // Step 7: resolve keyid.\n const jwk = await options.jwks.resolve(parsedInput.params.keyid);\n if (!jwk) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_unknown',\n 7,\n `No JWK found for keyid \"${parsedInput.params.keyid}\".`\n );\n }\n if (jwk.kid !== parsedInput.params.keyid) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_unknown',\n 7,\n `JWKS resolver returned a JWK whose kid \"${jwk.kid}\" does not match requested keyid \"${parsedInput.params.keyid}\".`\n );\n }\n\n // Step 8: key purpose — webhooks are signed with a `request-signing` key.\n //\n // Webhooks carry no separate key purpose: the signer uses its\n // `adcp_use: \"request-signing\"` key (the deprecated `\"webhook-signing\"`\n // value is still accepted here for backward compatibility, pending removal — adcontextprotocol/adcp#5555).\n // This is safe because cross-protocol confusion is prevented by the\n // signature `tag` (step 3, `adcp/webhook-signing/v1`, part of the signed\n // base) and mandatory `content-digest` coverage (step 6) — not by the\n // key-purpose discriminator. A captured request signature\n // (`tag=adcp/request-signing/v1`) can never be replayed against this\n // verifier because step 3 rejects it. Webhook key isolation, when wanted, is\n // a second `request-signing` key under a distinct `kid` — not a distinct\n // `adcp_use`.\n //\n // All key-purpose failures use `webhook_signature_key_purpose_invalid`:\n // absent `adcp_use`, a missing `verify` key_op, or an `adcp_use` outside the\n // accepted set (e.g. `response-signing`, `governance-signing`). We do NOT\n // reuse `webhook_mode_mismatch` here — that code is reserved for the\n // HMAC-vs-9421 auth-mode selector mismatch, and overloading it would collapse\n // two distinct failure classes onto one stable code receivers branch on.\n if (\n jwk.adcp_use === undefined ||\n !jwk.key_ops?.includes('verify') ||\n (jwk.adcp_use !== 'request-signing' && jwk.adcp_use !== 'webhook-signing')\n ) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_purpose_invalid',\n 8,\n `JWK \"${jwk.kid}\" is not scoped for webhook delivery: adcp_use must be \"request-signing\" (or the deprecated \"webhook-signing\") with a \"verify\" key_op (got adcp_use=\"${jwk.adcp_use}\").`\n );\n }\n\n // Step 9: revocation. Shared store throws `request_signature_revocation_stale`\n // when its cached snapshot is past grace — re-map to the webhook taxonomy\n // so callers see a consistent error surface.\n try {\n if (await options.revocationStore.isRevoked(jwk.kid)) {\n throw new WebhookSignatureError('webhook_signature_key_revoked', 9, `JWK \"${jwk.kid}\" is revoked.`);\n }\n } catch (err) {\n if (err instanceof RequestSignatureError && err.code === 'request_signature_revocation_stale') {\n throw new WebhookSignatureError('webhook_signature_revocation_stale', 9, err.message);\n }\n throw err;\n }\n\n // Replay cache is scoped by `(keyid, @target-uri)` per adcp#2460 — a\n // webhook captured on one receiver path MUST NOT count against the replay\n // budget for a different path under the same keyid. Canonicalize once and\n // reuse for both pre-check and commit.\n const replayScope = canonicalTargetUri(request.url);\n\n // Step 9a: per-keyid rate abuse. Distinct code from step 12's replay —\n // cap exhaustion is a compromised-key / misconfig signal that SHOULD\n // alert operators, not \"same nonce twice.\"\n if (await options.replayStore.isCapHit(jwk.kid, replayScope, now)) {\n throw new WebhookSignatureError(\n 'webhook_signature_rate_abuse',\n 9,\n `Per-keyid replay cache cap exceeded for keyid=${jwk.kid}.`\n );\n }\n\n // Pre-check step 12's replay before crypto so a replayed nonce short-\n // circuits an expensive Ed25519/ECDSA verify.\n if (await options.replayStore.has(jwk.kid, replayScope, parsedInput.params.nonce, now)) {\n throw new WebhookSignatureError(\n 'webhook_signature_replayed',\n 12,\n `Replay of (keyid=${jwk.kid}, nonce=${parsedInput.params.nonce}) within signature window.`\n );\n }\n\n // Step 10: cryptographic verify.\n const base = buildSignatureBase(\n parsedInput.components,\n request,\n parsedInput.params,\n parsedInput.signatureParamsValue\n );\n const publicKey = jwkToPublicKey(jwk);\n const valid = verifySignature(parsedInput.params.alg, publicKey, Buffer.from(base, 'utf8'), parsedSig.bytes);\n if (!valid) {\n throw new WebhookSignatureError(\n 'webhook_signature_invalid',\n 10,\n 'Cryptographic verification of webhook signature base failed.'\n );\n }\n\n // Step 11: content-digest match.\n const digestHeader = getHeaderValue(request.headers, 'Content-Digest');\n if (!digestHeader || !contentDigestMatches(digestHeader, request.body ?? '')) {\n throw new WebhookSignatureError(\n 'webhook_signature_digest_mismatch',\n 11,\n 'Content-Digest header does not match recomputed body hash.'\n );\n }\n\n // Step 13: commit nonce. Insert AFTER every prior check passes — this is\n // the load-bearing invariant that keeps external traffic from growing the\n // cap (any signature failing at step 10 never consumes an entry).\n const remaining = parsedInput.params.expires - now + CLOCK_SKEW_TOLERANCE_SECONDS;\n const ttl = Math.max(remaining, MAX_SIGNATURE_WINDOW_SECONDS + CLOCK_SKEW_TOLERANCE_SECONDS);\n const insertResult = await options.replayStore.insert(jwk.kid, replayScope, parsedInput.params.nonce, ttl, now);\n if (insertResult === 'replayed') {\n throw new WebhookSignatureError(\n 'webhook_signature_replayed',\n 13,\n `Replay of (keyid=${jwk.kid}, nonce=${parsedInput.params.nonce}) within signature window.`\n );\n }\n if (insertResult === 'rate_abuse') {\n throw new WebhookSignatureError(\n 'webhook_signature_rate_abuse',\n 13,\n `Per-keyid replay cache cap exceeded on commit for keyid=${jwk.kid}.`\n );\n }\n\n const agent_url = options.agentUrlForKeyid?.(jwk.kid);\n return { status: 'verified', keyid: jwk.kid, ...(agent_url !== undefined && { agent_url }), verified_at: now };\n}\n\nfunction requireParams(parsed: ParsedSignatureInput): void {\n const required: Array<keyof ParsedSignatureInput['params']> = ['created', 'expires', 'nonce', 'keyid', 'alg', 'tag'];\n const missing = required.filter(k => parsed.params[k] === undefined);\n if (missing.length) {\n throw new WebhookSignatureError(\n 'webhook_signature_params_incomplete',\n 2,\n `Signature-Input missing required parameter(s): ${missing.join(', ')}.`\n );\n }\n}\n\nfunction validateWindow(created: number, expires: number, now: number): void {\n // Spec folds every window-level failure — expired, negative window,\n // over-long window, created-in-future — into webhook_signature_window_invalid.\n // The error message carries the specific subtype for diagnostics.\n if (expires <= created) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n 'Signature expires must be strictly greater than created.'\n );\n }\n if (expires - created > MAX_SIGNATURE_WINDOW_SECONDS) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n `Signature window exceeds ${MAX_SIGNATURE_WINDOW_SECONDS}s maximum.`\n );\n }\n if (now < created - CLOCK_SKEW_TOLERANCE_SECONDS) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n 'Signature created is in the future beyond skew tolerance.'\n );\n }\n if (now > expires + CLOCK_SKEW_TOLERANCE_SECONDS) {\n throw new WebhookSignatureError('webhook_signature_window_invalid', 5, 'Signature is expired.');\n }\n}\n\nfunction validateCoveredComponents(components: string[]): void {\n for (const mandatory of WEBHOOK_MANDATORY_COMPONENTS) {\n if (!components.includes(mandatory)) {\n throw new WebhookSignatureError(\n 'webhook_signature_components_incomplete',\n 6,\n `Covered components must include \"${mandatory}\".`\n );\n }\n }\n}\n\n/**\n * Syntactic validation of the `@target-uri` value before signature\n * computation. Four failure modes:\n * - URL doesn't parse at all.\n * - Scheme is not https (webhooks MUST be TLS-terminated; loopback\n * addresses are exempt so `loopback_mock` test receivers — the\n * storyboard runner's default — can operate over plain http).\n * - Authority contains userinfo — credentials don't belong in a signed URI.\n * - URL carries a fragment — fragments are client-side and never transmitted.\n *\n * Each throws `webhook_target_uri_malformed` with the failure reason in the\n * message. Distinct from `webhook_signature_header_malformed`, which flags\n * the Signature / Signature-Input headers themselves.\n */\nfunction validateTargetUri(rawUrl: string): void {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n throw new WebhookSignatureError(\n 'webhook_target_uri_malformed',\n 6,\n `@target-uri \"${rawUrl}\" is not a parseable URL.`\n );\n }\n if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname)) {\n throw new WebhookSignatureError(\n 'webhook_target_uri_malformed',\n 6,\n `@target-uri must use https; got \"${url.protocol}\" in \"${rawUrl}\".`\n );\n }\n if (url.username || url.password) {\n throw new WebhookSignatureError('webhook_target_uri_malformed', 6, '@target-uri must not embed userinfo.');\n }\n if (url.hash) {\n throw new WebhookSignatureError('webhook_target_uri_malformed', 6, '@target-uri must not carry a fragment.');\n }\n}\n\n/**\n * Loopback host exemption for the https-only rule. The storyboard runner's\n * `loopback_mock` webhook receiver binds to 127.0.0.1 (or the IPv6 [::1])\n * and publishers emit `http://127.0.0.1:<port>/…` URLs for delivery; the\n * signature covers `@target-uri` byte-for-byte, so the verifier sees the\n * http scheme. Production traffic never reaches loopback, so this exemption\n * can't be used to bypass TLS for real webhooks.\n */\nfunction isLoopbackHost(hostname: string): boolean {\n if (!hostname) return false;\n const normalized = hostname.toLowerCase();\n return normalized === 'localhost' || normalized === '::1' || normalized.startsWith('127.');\n}\n\n/**\n * Options for {@link createWebhookVerifier}. Identical to\n * {@link VerifyWebhookOptions} except `replayStore` and `revocationStore` are\n * optional — the factory defaults them once at creation time so replay state\n * is shared across every request the returned verifier handles.\n */\nexport interface CreateWebhookVerifierOptions extends Omit<VerifyWebhookOptions, 'replayStore' | 'revocationStore'> {\n /**\n * Stores `(keyid, scope, nonce)` tuples for replay detection.\n * Defaults to a fresh {@link InMemoryReplayStore} — suitable for single-process\n * deployments only. **Multi-replica deployments MUST pass an explicit shared\n * store** (Redis, Postgres, etc.) — the default in-memory store does not\n * survive process boundaries, so a signature accepted on replica A is\n * invisible to replica B and can be replayed there within the signature window.\n */\n replayStore?: ReplayStore;\n /**\n * Consulted for revoked `kid` before accepting a signature.\n * Defaults to a fresh {@link InMemoryRevocationStore}, which starts empty\n * and does not poll for updates. The default is sufficient when you don't\n * revoke keys at runtime; when you do, pass a store backed by your secrets\n * manager or admin tooling so revocations take effect without redeployment.\n */\n revocationStore?: RevocationStore;\n}\n\n/**\n * Create a bound webhook-signature verifier with shared replay and revocation\n * stores. Mirrors {@link createExpressVerifier} for the webhook profile.\n *\n * The returned function is the per-request entry point: call it with each\n * inbound webhook's {@link RequestLike} representation.\n *\n * **Why a factory?** Replay detection requires the same store instance to be\n * consulted across every request. Constructing stores inside a per-request\n * call would silently defeat replay dedup — each call would start with an\n * empty store and always accept the nonce. The factory pattern captures the\n * store instances in closure scope at wire-up time, guaranteeing they are\n * shared across calls for the lifetime of the verifier.\n *\n * **Multi-replica deployments MUST pass an explicit `replayStore`** backed by\n * a shared persistence layer (Redis, Postgres, etc.) — the default\n * `InMemoryReplayStore` does not survive process boundaries. A nonce accepted\n * on replica A is invisible to replica B; replaying the webhook to B succeeds\n * silently within the signature window.\n */\nexport function createWebhookVerifier(\n options: CreateWebhookVerifierOptions\n): (request: RequestLike) => Promise<VerifyWebhookResult> {\n // Instantiate defaults once at creation time so every request handled by\n // this verifier shares the same replay / revocation state. Per-request\n // construction would defeat replay detection entirely.\n const replayStore = options.replayStore ?? new InMemoryReplayStore();\n const revocationStore = options.revocationStore ?? new InMemoryRevocationStore();\n return (request: RequestLike) => verifyWebhookSignature(request, { ...options, replayStore, revocationStore });\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/signing/webhook-verifier.ts"],"sourcesContent":["/**\n * RFC 9421 webhook-signing verifier (AdCP 3.0 webhook profile).\n *\n * Companion to `verifier.ts`, which verifies outbound request signatures.\n * This verifier runs on the receiving side of a publisher webhook: the\n * storyboard runner's receiver captures a POST and hands the request (headers\n * + raw body) here for signature validation.\n *\n * Distinct from request-signing:\n * - Tag: `adcp/webhook-signing/v1` (vs `adcp/request-signing/v1`).\n * - Key purpose: `adcp_use: \"webhook-signing\"` OR `\"request-signing\"` — a\n * signer may reuse its request-signing key for webhooks (see step 8).\n * - Covered components MUST include `@method`, `@target-uri`, `@authority`,\n * `content-type`, and `content-digest` — `content-digest` is unconditional\n * for webhooks (vs policy-driven on requests) because every webhook\n * carries a JSON body.\n * - JWKS resolved via the publisher's `brand.json` `agents[]` `jwks_uri`,\n * not the request-path capability document.\n *\n * Checklist steps below mirror the 14-step shape in\n * `docs/building/implementation/security.mdx#verifier-checklist-for-webhooks`\n * so failures can point at a specific spec clause. Numbers are 1-based to\n * match the security doc.\n */\n\nimport { buildSignatureBase, canonicalTargetUri, getHeaderValue, type RequestLike } from './canonicalize';\nimport { contentDigestMatches } from './content-digest';\nimport { RequestSignatureError, WebhookSignatureError } from './errors';\nimport { parseSignature, parseSignatureInput, type ParsedSignatureInput } from './parser';\nimport { jwkToPublicKey, verifySignature } from './crypto';\nimport type { JwksResolver } from './jwks';\nimport { InMemoryReplayStore, type ReplayStore } from './replay';\nimport { InMemoryRevocationStore, type RevocationStore } from './revocation';\nimport { ALLOWED_ALGS, CLOCK_SKEW_TOLERANCE_SECONDS, MAX_SIGNATURE_WINDOW_SECONDS } from './types';\n\nexport const WEBHOOK_SIGNING_TAG = 'adcp/webhook-signing/v1';\n\n/**\n * Covered-component minimum for webhooks. Unlike request-signing, every\n * webhook carries a body so `content-type` and `content-digest` are\n * unconditional — a webhook without `content-digest` coverage can't be\n * verified because the body has no anchor.\n */\nexport const WEBHOOK_MANDATORY_COMPONENTS: ReadonlyArray<string> = [\n '@method',\n '@target-uri',\n '@authority',\n 'content-type',\n 'content-digest',\n];\n\nexport interface VerifyWebhookOptions {\n jwks: JwksResolver;\n replayStore: ReplayStore;\n revocationStore: RevocationStore;\n /** Now in seconds since epoch. Defaults to `Date.now() / 1000`. */\n now?: () => number;\n /**\n * Optional tag override — spec currently defines exactly\n * `adcp/webhook-signing/v1`; the override lets test vectors pin a version.\n */\n requiredTag?: string;\n /** Optional reverse-lookup (kid → publisher URL) for result attribution. */\n agentUrlForKeyid?: (keyid: string) => string | undefined;\n}\n\nexport interface VerifyWebhookResult {\n status: 'verified';\n keyid: string;\n agent_url?: string;\n verified_at: number;\n}\n\n/**\n * Verify an inbound webhook's RFC 9421 signature.\n *\n * Checklist steps 1–13 plus sub-step 9a, matching the canonical numbering\n * in `security.mdx#verifier-checklist-for-webhooks` (and the `failed_step`\n * values on the conformance vectors at\n * `test-vectors/webhook-signing/negative/`). Throws\n * `WebhookSignatureError` on the first failed step.\n *\n * Ordering invariant: cheap invariant checks (tag, alg, window, components)\n * run before JWKS resolution (step 7); revocation (9) and rate-abuse (9a)\n * run before cryptographic verify (10) so an attacker can't amplify\n * Ed25519/ECDSA work. Replay insert (13) commits only after every earlier\n * step has passed — any signature failing at step 10 never consumes a cap\n * entry, so external traffic can't grow the per-keyid cache.\n */\nexport async function verifyWebhookSignature(\n request: RequestLike,\n options: VerifyWebhookOptions\n): Promise<VerifyWebhookResult> {\n const now = options.now ? options.now() : Math.floor(Date.now() / 1000);\n const requiredTag = options.requiredTag ?? WEBHOOK_SIGNING_TAG;\n\n // Step 1: both signature headers present AND parseable. The bound-pair\n // rule (`Signature` without `Signature-Input` or vice versa) rejects here.\n const sigInputHeader = getHeaderValue(request.headers, 'Signature-Input');\n const sigHeader = getHeaderValue(request.headers, 'Signature');\n if (!sigInputHeader || !sigHeader) {\n throw new WebhookSignatureError(\n 'webhook_signature_header_malformed',\n 1,\n 'Webhook is missing Signature or Signature-Input headers.'\n );\n }\n let parsedInput: ParsedSignatureInput;\n let parsedSig: ReturnType<typeof parseSignature>;\n try {\n parsedInput = parseSignatureInput(sigInputHeader);\n parsedSig = parseSignature(sigHeader, parsedInput.label);\n } catch (err) {\n throw new WebhookSignatureError(\n 'webhook_signature_header_malformed',\n 1,\n err instanceof Error ? err.message : String(err)\n );\n }\n\n // Step 2: required params present.\n requireParams(parsedInput);\n\n // Step 3: tag match.\n if (parsedInput.params.tag !== requiredTag) {\n throw new WebhookSignatureError(\n 'webhook_signature_tag_invalid',\n 3,\n `Signature tag must be \"${requiredTag}\"; got \"${parsedInput.params.tag}\".`\n );\n }\n\n // Step 4: alg allowlist.\n if (!ALLOWED_ALGS.has(parsedInput.params.alg)) {\n throw new WebhookSignatureError(\n 'webhook_signature_alg_not_allowed',\n 4,\n `Signature alg \"${parsedInput.params.alg}\" is not in the AdCP allowlist.`\n );\n }\n\n // Step 5: window valid.\n validateWindow(parsedInput.params.created, parsedInput.params.expires, now);\n\n // Step 6: covered components.\n validateCoveredComponents(parsedInput.components);\n\n // Step 6a: `@target-uri` syntactic validation. Runs before JWKS resolution\n // and crypto verify so a malformed URI short-circuits without JWKS\n // side-effects. Distinct from `header_malformed` (the Signature /\n // Signature-Input headers themselves) — this flags the covered URI value\n // that will be fed into the signature base. Non-parseable URLs, non-https\n // schemes, embedded userinfo, and fragments are all rejected.\n validateTargetUri(request.url);\n\n // Step 7: resolve keyid.\n const jwk = await options.jwks.resolve(parsedInput.params.keyid);\n if (!jwk) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_unknown',\n 7,\n `No JWK found for keyid \"${parsedInput.params.keyid}\".`\n );\n }\n if (jwk.kid !== parsedInput.params.keyid) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_unknown',\n 7,\n `JWKS resolver returned a JWK whose kid \"${jwk.kid}\" does not match requested keyid \"${parsedInput.params.keyid}\".`\n );\n }\n\n // Step 8: key purpose — webhooks are signed with a `request-signing` key.\n //\n // Webhooks carry no separate key purpose: the signer uses its\n // `adcp_use: \"request-signing\"` key (the deprecated `\"webhook-signing\"`\n // value is still accepted here for backward compatibility, pending removal — adcontextprotocol/adcp#5555).\n // This is safe because cross-protocol confusion is prevented by the\n // signature `tag` (step 3, `adcp/webhook-signing/v1`, part of the signed\n // base) and mandatory `content-digest` coverage (step 6) — not by the\n // key-purpose discriminator. A captured request signature\n // (`tag=adcp/request-signing/v1`) can never be replayed against this\n // verifier because step 3 rejects it. Webhook key isolation, when wanted, is\n // a second `request-signing` key under a distinct `kid` — not a distinct\n // `adcp_use`.\n //\n // All key-purpose failures use `webhook_signature_key_purpose_invalid`:\n // absent `adcp_use`, a missing `verify` key_op, or an `adcp_use` outside the\n // accepted set (e.g. `response-signing`, `governance-signing`). We do NOT\n // reuse `webhook_mode_mismatch` here — that code is reserved for the\n // HMAC-vs-9421 auth-mode selector mismatch, and overloading it would collapse\n // two distinct failure classes onto one stable code receivers branch on.\n if (\n jwk.adcp_use === undefined ||\n !jwk.key_ops?.includes('verify') ||\n (jwk.adcp_use !== 'request-signing' && jwk.adcp_use !== 'webhook-signing')\n ) {\n throw new WebhookSignatureError(\n 'webhook_signature_key_purpose_invalid',\n 8,\n `JWK \"${jwk.kid}\" is not scoped for webhook delivery: adcp_use must be \"request-signing\" (or the deprecated \"webhook-signing\") with a \"verify\" key_op (got adcp_use=\"${jwk.adcp_use}\").`\n );\n }\n\n // Step 9: revocation. Shared store throws `request_signature_revocation_stale`\n // when its cached snapshot is past grace — re-map to the webhook taxonomy\n // so callers see a consistent error surface.\n try {\n if (await options.revocationStore.isRevoked(jwk.kid)) {\n throw new WebhookSignatureError('webhook_signature_key_revoked', 9, `JWK \"${jwk.kid}\" is revoked.`);\n }\n } catch (err) {\n if (err instanceof RequestSignatureError && err.code === 'request_signature_revocation_stale') {\n throw new WebhookSignatureError('webhook_signature_revocation_stale', 9, err.message);\n }\n throw err;\n }\n\n // Replay cache is scoped by `(keyid, @target-uri)` per adcp#2460 — a\n // webhook captured on one receiver path MUST NOT count against the replay\n // budget for a different path under the same keyid. Canonicalize once and\n // reuse for both pre-check and commit.\n const replayScope = canonicalTargetUri(request.url);\n\n // Step 9a: per-keyid rate abuse. Distinct code from step 12's replay —\n // cap exhaustion is a compromised-key / misconfig signal that SHOULD\n // alert operators, not \"same nonce twice.\"\n if (await options.replayStore.isCapHit(jwk.kid, replayScope, now)) {\n throw new WebhookSignatureError(\n 'webhook_signature_rate_abuse',\n 9,\n `Per-keyid replay cache cap exceeded for keyid=${jwk.kid}.`\n );\n }\n\n // Pre-check step 12's replay before crypto so a replayed nonce short-\n // circuits an expensive Ed25519/ECDSA verify.\n if (await options.replayStore.has(jwk.kid, replayScope, parsedInput.params.nonce, now)) {\n throw new WebhookSignatureError(\n 'webhook_signature_replayed',\n 12,\n `Replay of (keyid=${jwk.kid}, nonce=${parsedInput.params.nonce}) within signature window.`\n );\n }\n\n // Step 10: cryptographic verify.\n const base = buildSignatureBase(\n parsedInput.components,\n request,\n parsedInput.params,\n parsedInput.signatureParamsValue\n );\n const publicKey = jwkToPublicKey(jwk);\n const valid = verifySignature(parsedInput.params.alg, publicKey, Buffer.from(base, 'utf8'), parsedSig.bytes);\n if (!valid) {\n throw new WebhookSignatureError(\n 'webhook_signature_invalid',\n 10,\n 'Cryptographic verification of webhook signature base failed.'\n );\n }\n\n // Step 11: content-digest match.\n const digestHeader = getHeaderValue(request.headers, 'Content-Digest');\n if (!digestHeader || !contentDigestMatches(digestHeader, request.body ?? '')) {\n throw new WebhookSignatureError(\n 'webhook_signature_digest_mismatch',\n 11,\n 'Content-Digest header does not match recomputed body hash.'\n );\n }\n\n // Step 13: commit nonce. Insert AFTER every prior check passes — this is\n // the load-bearing invariant that keeps external traffic from growing the\n // cap (any signature failing at step 10 never consumes an entry).\n const remaining = parsedInput.params.expires - now + CLOCK_SKEW_TOLERANCE_SECONDS;\n const ttl = Math.max(remaining, MAX_SIGNATURE_WINDOW_SECONDS + CLOCK_SKEW_TOLERANCE_SECONDS);\n const insertResult = await options.replayStore.insert(jwk.kid, replayScope, parsedInput.params.nonce, ttl, now);\n if (insertResult === 'replayed') {\n throw new WebhookSignatureError(\n 'webhook_signature_replayed',\n 13,\n `Replay of (keyid=${jwk.kid}, nonce=${parsedInput.params.nonce}) within signature window.`\n );\n }\n if (insertResult === 'rate_abuse') {\n throw new WebhookSignatureError(\n 'webhook_signature_rate_abuse',\n 13,\n `Per-keyid replay cache cap exceeded on commit for keyid=${jwk.kid}.`\n );\n }\n\n const agent_url = options.agentUrlForKeyid?.(jwk.kid);\n return { status: 'verified', keyid: jwk.kid, ...(agent_url !== undefined && { agent_url }), verified_at: now };\n}\n\nfunction requireParams(parsed: ParsedSignatureInput): void {\n const required: Array<keyof ParsedSignatureInput['params']> = ['created', 'expires', 'nonce', 'keyid', 'alg', 'tag'];\n const missing = required.filter(k => parsed.params[k] === undefined);\n if (missing.length) {\n throw new WebhookSignatureError(\n 'webhook_signature_params_incomplete',\n 2,\n `Signature-Input missing required parameter(s): ${missing.join(', ')}.`\n );\n }\n}\n\nfunction validateWindow(created: number, expires: number, now: number): void {\n // Spec folds every window-level failure — expired, negative window,\n // over-long window, created-in-future — into webhook_signature_window_invalid.\n // The error message carries the specific subtype for diagnostics.\n if (expires <= created) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n 'Signature expires must be strictly greater than created.'\n );\n }\n if (expires - created > MAX_SIGNATURE_WINDOW_SECONDS) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n `Signature window exceeds ${MAX_SIGNATURE_WINDOW_SECONDS}s maximum.`\n );\n }\n if (now < created - CLOCK_SKEW_TOLERANCE_SECONDS) {\n throw new WebhookSignatureError(\n 'webhook_signature_window_invalid',\n 5,\n 'Signature created is in the future beyond skew tolerance.'\n );\n }\n if (now > expires + CLOCK_SKEW_TOLERANCE_SECONDS) {\n throw new WebhookSignatureError('webhook_signature_window_invalid', 5, 'Signature is expired.');\n }\n}\n\nfunction validateCoveredComponents(components: string[]): void {\n for (const mandatory of WEBHOOK_MANDATORY_COMPONENTS) {\n if (!components.includes(mandatory)) {\n throw new WebhookSignatureError(\n 'webhook_signature_components_incomplete',\n 6,\n `Covered components must include \"${mandatory}\".`\n );\n }\n }\n}\n\n/**\n * Syntactic validation of the `@target-uri` value before signature\n * computation. Four failure modes:\n * - URL doesn't parse at all.\n * - Scheme is not https (webhooks MUST be TLS-terminated; loopback\n * addresses are exempt so `loopback_mock` test receivers — the\n * storyboard runner's default — can operate over plain http).\n * - Authority contains userinfo — credentials don't belong in a signed URI.\n * - URL carries a fragment — fragments are client-side and never transmitted.\n *\n * Each throws `webhook_target_uri_malformed` with the failure reason in the\n * message. Distinct from `webhook_signature_header_malformed`, which flags\n * the Signature / Signature-Input headers themselves.\n */\nfunction validateTargetUri(rawUrl: string): void {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n throw new WebhookSignatureError(\n 'webhook_target_uri_malformed',\n 6,\n `@target-uri \"${rawUrl}\" is not a parseable URL.`\n );\n }\n if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname)) {\n throw new WebhookSignatureError(\n 'webhook_target_uri_malformed',\n 6,\n `@target-uri must use https; got \"${url.protocol}\" in \"${rawUrl}\".`\n );\n }\n if (url.username || url.password) {\n throw new WebhookSignatureError('webhook_target_uri_malformed', 6, '@target-uri must not embed userinfo.');\n }\n if (url.hash) {\n throw new WebhookSignatureError('webhook_target_uri_malformed', 6, '@target-uri must not carry a fragment.');\n }\n}\n\n/**\n * Loopback host exemption for the https-only rule. The storyboard runner's\n * `loopback_mock` webhook receiver binds to 127.0.0.1 (or the IPv6 [::1])\n * and publishers emit `http://127.0.0.1:<port>/…` URLs for delivery; the\n * signature covers `@target-uri` byte-for-byte, so the verifier sees the\n * http scheme.\n *\n * The exemption is restricted to IPv4 literals in 127.0.0.0/8, the IPv6\n * loopback, and the literal name `localhost`. A prefix test would also match\n * registered names like `127.example.com`, which resolve to arbitrary public\n * addresses and would let a real webhook drop TLS.\n */\n// Only reached with an already-parsed `URL.hostname`, so WHATWG has normalized\n// `127.1` / `0x7f000001` to dotted-quad form and rejected out-of-range octets\n// before this sees them. No octet-range check is needed (or reachable).\nconst LOOPBACK_IPV4 = /^127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/;\n\nfunction isLoopbackHost(hostname: string): boolean {\n if (!hostname) return false;\n // Node's `URL.hostname` keeps IPv6 literals bracketed; a FQDN keeps its dot.\n const normalized = hostname.toLowerCase().replace(/\\.$/, '');\n if (normalized === 'localhost' || normalized === '::1' || normalized === '[::1]') return true;\n return LOOPBACK_IPV4.test(normalized);\n}\n\n/**\n * Options for {@link createWebhookVerifier}. Identical to\n * {@link VerifyWebhookOptions} except `replayStore` and `revocationStore` are\n * optional — the factory defaults them once at creation time so replay state\n * is shared across every request the returned verifier handles.\n */\nexport interface CreateWebhookVerifierOptions extends Omit<VerifyWebhookOptions, 'replayStore' | 'revocationStore'> {\n /**\n * Stores `(keyid, scope, nonce)` tuples for replay detection.\n * Defaults to a fresh {@link InMemoryReplayStore} — suitable for single-process\n * deployments only. **Multi-replica deployments MUST pass an explicit shared\n * store** (Redis, Postgres, etc.) — the default in-memory store does not\n * survive process boundaries, so a signature accepted on replica A is\n * invisible to replica B and can be replayed there within the signature window.\n */\n replayStore?: ReplayStore;\n /**\n * Consulted for revoked `kid` before accepting a signature.\n * Defaults to a fresh {@link InMemoryRevocationStore}, which starts empty\n * and does not poll for updates. The default is sufficient when you don't\n * revoke keys at runtime; when you do, pass a store backed by your secrets\n * manager or admin tooling so revocations take effect without redeployment.\n */\n revocationStore?: RevocationStore;\n}\n\n/**\n * Create a bound webhook-signature verifier with shared replay and revocation\n * stores. Mirrors {@link createExpressVerifier} for the webhook profile.\n *\n * The returned function is the per-request entry point: call it with each\n * inbound webhook's {@link RequestLike} representation.\n *\n * **Why a factory?** Replay detection requires the same store instance to be\n * consulted across every request. Constructing stores inside a per-request\n * call would silently defeat replay dedup — each call would start with an\n * empty store and always accept the nonce. The factory pattern captures the\n * store instances in closure scope at wire-up time, guaranteeing they are\n * shared across calls for the lifetime of the verifier.\n *\n * **Multi-replica deployments MUST pass an explicit `replayStore`** backed by\n * a shared persistence layer (Redis, Postgres, etc.) — the default\n * `InMemoryReplayStore` does not survive process boundaries. A nonce accepted\n * on replica A is invisible to replica B; replaying the webhook to B succeeds\n * silently within the signature window.\n */\nexport function createWebhookVerifier(\n options: CreateWebhookVerifierOptions\n): (request: RequestLike) => Promise<VerifyWebhookResult> {\n // Instantiate defaults once at creation time so every request handled by\n // this verifier shares the same replay / revocation state. Per-request\n // construction would defeat replay detection entirely.\n const replayStore = options.replayStore ?? new InMemoryReplayStore();\n const revocationStore = options.revocationStore ?? new InMemoryRevocationStore();\n return (request: RequestLike) => verifyWebhookSignature(request, { ...options, replayStore, revocationStore });\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-tester.d.ts","sourceRoot":"","sources":["../../../src/lib/testing/agent-tester.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,YAAY,EACV,YAAY,EACZ,WAAW,EACX,cAAc,EACd,YAAY,EACZ,UAAU,EACV,WAAW,EACX,UAAU,EACV,MAAM,GACP,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAG3C,OAAO,EAAE,oBAAoB,EAAE,SAAS,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAGtF,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;AA0DjG,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAgC,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"agent-tester.d.ts","sourceRoot":"","sources":["../../../src/lib/testing/agent-tester.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,YAAY,EACV,YAAY,EACZ,WAAW,EACX,cAAc,EACd,YAAY,EACZ,UAAU,EACV,WAAW,EACX,UAAU,EACV,MAAM,GACP,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAG3C,OAAO,EAAE,oBAAoB,EAAE,SAAS,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAGtF,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;AA0DjG,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAgC,MAAM,SAAS,CAAC;AA0FnG;;GAEG;AACH,wBAAsB,SAAS,CAC7B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,YAAY,EACtB,OAAO,GAAE,WAAgB,GACxB,OAAO,CAAC,UAAU,CAAC,CAyWrB;AAGD,OAAO,EACL,gBAAgB,EAChB,sBAAsB,EACtB,qBAAqB,EACrB,iBAAiB,EACjB,kBAAkB,EAClB,sBAAsB,EACtB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EACL,eAAe,EACf,aAAa,EACb,kBAAkB,EAClB,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,EAChB,kBAAkB,EAClB,gBAAgB,EAChB,qBAAqB,EACrB,eAAe,EACf,iBAAiB,EACjB,cAAc,EACd,oBAAoB,EACpB,sBAAsB,EACtB,oBAAoB,EACpB,uBAAuB,EAEvB,2BAA2B,EAC3B,8BAA8B,EAC9B,uBAAuB,EACvB,sBAAsB,EACtB,4BAA4B,EAC5B,gCAAgC,EAChC,8BAA8B,EAC9B,2BAA2B,EAC3B,sBAAsB,EACtB,kBAAkB,EAClB,aAAa,EACb,uBAAuB,EACvB,iBAAiB,EACjB,yBAAyB,EACzB,0BAA0B,EAC1B,oBAAoB,EACpB,cAAc,EACd,kBAAkB,EAClB,kBAAkB,EAElB,qBAAqB,EACrB,4BAA4B,EAC5B,oBAAoB,EAEpB,wBAAwB,EACxB,wBAAwB,EACxB,uBAAuB,EACvB,uBAAuB,EACvB,sBAAsB,EACtB,oBAAoB,EACpB,wBAAwB,EAExB,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,EAEnB,kBAAkB,EAClB,0BAA0B,EAC1B,UAAU,EACV,gBAAgB,GACjB,MAAM,aAAa,CAAC"}
|
|
@@ -93,6 +93,51 @@ var import_scenarios = require('./scenarios/index.js');
|
|
|
93
93
|
var import_client2 = require('./client.js');
|
|
94
94
|
var import_orchestrator = require('./orchestrator.js');
|
|
95
95
|
var import_scenarios2 = require('./scenarios/index.js');
|
|
96
|
+
const REDACTED = "[redacted]";
|
|
97
|
+
function redactTestOptions(options) {
|
|
98
|
+
const safe = {};
|
|
99
|
+
for (const key of LOGGABLE_OPTION_KEYS) {
|
|
100
|
+
if (options[key] !== void 0) safe[key] = options[key];
|
|
101
|
+
}
|
|
102
|
+
if (options.auth) {
|
|
103
|
+
safe.auth = { type: options.auth.type, ...redactedFieldsFor(options.auth) };
|
|
104
|
+
}
|
|
105
|
+
if (options.headers) {
|
|
106
|
+
safe.headers = Object.fromEntries(Object.keys(options.headers).map((name) => [name, REDACTED]));
|
|
107
|
+
}
|
|
108
|
+
if (options.test_kit) {
|
|
109
|
+
safe.test_kit = { keys: Object.keys(options.test_kit) };
|
|
110
|
+
}
|
|
111
|
+
return safe;
|
|
112
|
+
}
|
|
113
|
+
const LOGGABLE_OPTION_KEYS = [
|
|
114
|
+
"protocol",
|
|
115
|
+
"adcpVersion",
|
|
116
|
+
"wireAdcpVersion",
|
|
117
|
+
"versionEnvelope",
|
|
118
|
+
"schemaRoot",
|
|
119
|
+
"userAgent",
|
|
120
|
+
"brand",
|
|
121
|
+
"brief",
|
|
122
|
+
"budget",
|
|
123
|
+
"format_ids",
|
|
124
|
+
"sandbox",
|
|
125
|
+
"test_session_id"
|
|
126
|
+
];
|
|
127
|
+
function redactedFieldsFor(auth) {
|
|
128
|
+
switch (auth.type) {
|
|
129
|
+
case "bearer":
|
|
130
|
+
return { token: REDACTED };
|
|
131
|
+
case "basic":
|
|
132
|
+
return { username: REDACTED, password: REDACTED };
|
|
133
|
+
case "oauth":
|
|
134
|
+
return { tokens: REDACTED };
|
|
135
|
+
case "oauth_client_credentials":
|
|
136
|
+
return { credentials: REDACTED, tokens: REDACTED };
|
|
137
|
+
default:
|
|
138
|
+
return { value: REDACTED };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
96
141
|
async function testAgent(agentUrl, scenario, options = {}) {
|
|
97
142
|
const startTime = Date.now();
|
|
98
143
|
let steps = [];
|
|
@@ -103,7 +148,7 @@ async function testAgent(agentUrl, scenario, options = {}) {
|
|
|
103
148
|
sandbox: options.sandbox !== false,
|
|
104
149
|
test_session_id: options.test_session_id || `addie-test-${Date.now()}`
|
|
105
150
|
};
|
|
106
|
-
logger.info({ agentUrl, scenario, options: effectiveOptions }, "Starting agent test");
|
|
151
|
+
logger.info({ agentUrl, scenario, options: redactTestOptions(effectiveOptions) }, "Starting agent test");
|
|
107
152
|
try {
|
|
108
153
|
let result;
|
|
109
154
|
switch (scenario) {
|