From 58b2ee1091c7a5eb1c3d56604fbe31370056fd86 Mon Sep 17 00:00:00 2001 From: Tom Waite Date: Wed, 19 Aug 2026 16:47:22 -0700 Subject: [PATCH 01/10] chore: fix test runs on fresh checkouts Override ajv to v8 (root-hoisted v6 from eslint broke ajv-draft-04 via incur -> @readme/openapi-parser) and build core before x402/cli tests, since the workspace symlink resolves through core's dist/ exports map. Co-Authored-By: Claude Fable 5 --- bun.lock | 27 ++++++--------------------- cli/package.json | 2 +- package.json | 3 +++ x402/package.json | 3 ++- 4 files changed, 12 insertions(+), 23 deletions(-) diff --git a/bun.lock b/bun.lock index c4e0235..1fe8333 100644 --- a/bun.lock +++ b/bun.lock @@ -46,7 +46,7 @@ "name": "@worldcoin/agentkit", "version": "0.2.1", "dependencies": { - "@worldcoin/agentkit-core": "^0.1.8", + "@worldcoin/agentkit-core": "^0.2.1", "@x402/core": "^2.4.0", "viem": "^2.46.2", }, @@ -57,6 +57,9 @@ }, }, }, + "overrides": { + "ajv": "^8.18.0", + }, "packages": { "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], @@ -314,7 +317,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -444,8 +447,6 @@ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], @@ -544,7 +545,7 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -662,8 +663,6 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="], "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], @@ -776,8 +775,6 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -818,16 +815,10 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@readme/openapi-parser/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "@worldcoin/idkit-core/ox": ["ox@0.1.8", "", { "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-GJl6uKXxhPq/XgyvAnIokGuGU/pt9CU8reRJjzi4a02HOpLc2CEXXD4bRCITFFAzdRqHj3DQ6GDS7PlCytPM/A=="], - "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "incur/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -842,12 +833,6 @@ "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "@readme/openapi-parser/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], diff --git a/cli/package.json b/cli/package.json index ef829b8..4bba804 100644 --- a/cli/package.json +++ b/cli/package.json @@ -22,7 +22,7 @@ "scripts": { "build": "tsc", "cli": "tsx src/index.ts", - "test": "bun test" + "test": "bun run --cwd ../core build && bun test" }, "dependencies": { "@worldcoin/idkit-core": "2.1.0", diff --git a/package.json b/package.json index f9be6e3..37cf4d1 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ ] }, "private": true, + "overrides": { + "ajv": "^8.18.0" + }, "workspaces": [ "core", "x402", diff --git a/x402/package.json b/x402/package.json index 940a832..77c78f0 100644 --- a/x402/package.json +++ b/x402/package.json @@ -29,7 +29,8 @@ "access": "public" }, "scripts": { - "build": "tsup" + "build": "tsup", + "test": "bun run --cwd ../core build && bun test" }, "dependencies": { "@worldcoin/agentkit-core": "^0.2.1", From bdfaab72b9f7a347a2a2d7102ad21ed0e7db460d Mon Sep 17 00:00:00 2001 From: Tom Waite Date: Wed, 19 Aug 2026 16:52:01 -0700 Subject: [PATCH 02/10] feat(core): replace bare body signature with RFC 9421 message signatures The X-AgentKit bare EIP-191 body signature was a permanent, universal bearer credential (no audience, method, path, expiry, or nonce binding) and a cross-protocol signing oracle. Requests are now signed under a closed RFC 9421 profile covering @method, @authority, @path, @query, and content-digest (RFC 9530), with created/expires/nonce/keyid/tag params, EIP-191 over the signature base, and recovered-signer == keyid. Co-Authored-By: Claude Fable 5 --- core/src/index.ts | 5 +- core/src/signature.ts | 230 +++++++++++++++++++++++++++++++++++ core/src/verify.ts | 115 +++++++++++++++--- core/tests/exports.test.ts | 6 +- core/tests/signature.test.ts | 218 +++++++++++++++++++++++++++++++++ core/tests/verify.test.ts | 207 ++++++++++++++++++++++++------- 6 files changed, 717 insertions(+), 64 deletions(-) create mode 100644 core/src/signature.ts create mode 100644 core/tests/signature.test.ts diff --git a/core/src/index.ts b/core/src/index.ts index 3076f0e..db0312a 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1 +1,4 @@ -export { verify } from './verify' +export { verify, verifyRequest } from './verify' +export type { VerifiedAgentRequest } from './verify' +export { createSignatureHeaders } from './signature' +export type { AgentkitSignatureHeaders, CreateSignatureHeadersInput } from './signature' diff --git a/core/src/signature.ts b/core/src/signature.ts new file mode 100644 index 0000000..8bad641 --- /dev/null +++ b/core/src/signature.ts @@ -0,0 +1,230 @@ +import { bytesToHex, hexToBytes, sha256, type Hex } from 'viem' + +/** + * AgentKit profile of RFC 9421 (HTTP Message Signatures) + RFC 9530 (Content-Digest). + * + * The profile is closed: exactly one signature labeled `agentkit`, covering exactly + * `("@method" "@authority" "@path" "@query" "content-digest")`, with the parameters + * `created`, `expires`, `nonce`, `keyid` (lowercase agent address) and `tag="agentkit"` + * in that order. The signature is a 65-byte EIP-191 `personal_sign` over the UTF-8 + * signature base, transported as standard padded base64. Anything outside this exact + * shape is rejected. + * + * Example signature base for `POST https://api.example.com/data?x=1` with body `{"a":1}`: + * + * "@method": POST + * "@authority": api.example.com + * "@path": /data + * "@query": ?x=1 + * "content-digest": sha-256=:AVq9f1zFei3ZS3WQ8ErYCEJzkF7jPsXOvq5iJ2qX+GI=: + * "@signature-params": ("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;nonce="mAyU1DSTCXHDXqzm5g1D3A==";keyid="0x0123456789abcdef0123456789abcdef01234567";tag="agentkit" + */ + +export const SIGNATURE_INPUT_HEADER = 'Signature-Input' +export const SIGNATURE_HEADER = 'Signature' +export const CONTENT_DIGEST_HEADER = 'Content-Digest' +export const SIGNATURE_LABEL = 'agentkit' +export const MAX_SIGNATURE_AGE_SECONDS = 300 +export const CLOCK_SKEW_SECONDS = 5 + +const MAX_HEADER_LENGTH = 4096 +const COVERED_COMPONENTS = '("@method" "@authority" "@path" "@query" "content-digest")' +const KEYID_PATTERN = /^0x[0-9a-f]{40}$/ +// Printable ASCII excluding `"` and `\`, so the value never needs sf-string escaping. +const NONCE_PATTERN = /^[\x20-\x21\x23-\x5B\x5D-\x7E]{16,256}$/ +const TIMESTAMP = '(0|[1-9][0-9]{0,14})' +const SIGNATURE_PARAMS_PATTERN = new RegExp( + `^\\("@method" "@authority" "@path" "@query" "content-digest"\\);created=${TIMESTAMP};expires=${TIMESTAMP};nonce="([\\x20-\\x21\\x23-\\x5B\\x5D-\\x7E]{16,256})";keyid="(0x[0-9a-f]{40})";tag="agentkit"$` +) +// 65 signature bytes and 32 digest bytes always encode to these exact padded lengths. +const SIGNATURE_VALUE_PATTERN = /^agentkit=:([A-Za-z0-9+/]{87}=):$/ +const CONTENT_DIGEST_PATTERN = /^sha-256=:([A-Za-z0-9+/]{43}=):$/ + +export interface SignatureParams { + created: number + expires: number + nonce: string + keyid: string +} + +export interface ParsedSignatureInput extends SignatureParams { + /** The verbatim member value after `agentkit=`, reused byte-for-byte in the signature base. */ + rawParams: string +} + +export interface AgentkitSignatureHeaders { + 'Content-Digest': string + 'Signature-Input': string + Signature: string +} + +export interface CreateSignatureHeadersInput { + method: string + url: string | URL + /** Exact request body. Defaults to the empty body. */ + body?: string | Uint8Array + /** The agent's address; becomes the lowercase `keyid`. */ + address: string + /** EIP-191 signer over the UTF-8 signature base. */ + signMessage: (message: string) => Promise + /** Unix seconds; defaults to the current time. */ + now?: number + expiresInSeconds?: number + nonce?: string +} + +export function deriveComponents(method: string, url: string | URL) { + const parsed = typeof url === 'string' ? new URL(url) : url + return { + method: method.toUpperCase(), + authority: parsed.host, + path: parsed.pathname, + // RFC 9421 §2.2.7: an absent query serializes as a lone `?`. + query: parsed.search === '' ? '?' : parsed.search, + } +} + +export function computeContentDigest(bodyBytes: Uint8Array): string { + return `sha-256=:${encodeBase64(sha256(bodyBytes, 'bytes'))}:` +} + +export function serializeSignatureParams({ created, expires, nonce, keyid }: SignatureParams): string { + if (!Number.isInteger(created) || created < 0 || !Number.isInteger(expires) || expires <= created) { + throw new Error('Signature params require integer timestamps with expires after created') + } + if (!NONCE_PATTERN.test(nonce)) throw new Error('Signature nonce must be 16-256 printable ASCII characters') + if (!KEYID_PATTERN.test(keyid)) throw new Error('Signature keyid must be a lowercase 0x address') + + return `${COVERED_COMPONENTS};created=${created};expires=${expires};nonce="${nonce}";keyid="${keyid}";tag="${SIGNATURE_LABEL}"` +} + +export function buildSignatureBase(input: { + method: string + url: string | URL + contentDigest: string + signatureParams: string +}): string { + const components = deriveComponents(input.method, input.url) + return [ + `"@method": ${components.method}`, + `"@authority": ${components.authority}`, + `"@path": ${components.path}`, + `"@query": ${components.query}`, + `"content-digest": ${input.contentDigest}`, + `"@signature-params": ${input.signatureParams}`, + ].join('\n') +} + +export function parseSignatureInput(raw: string): ParsedSignatureInput { + if (raw.length > MAX_HEADER_LENGTH) throw new Error('Signature-Input header is too long') + + const prefix = `${SIGNATURE_LABEL}=` + if (!raw.startsWith(prefix)) throw new Error('Signature-Input must contain exactly the agentkit signature') + + const rawParams = raw.slice(prefix.length) + const match = SIGNATURE_PARAMS_PATTERN.exec(rawParams) + if (!match) throw new Error('Signature-Input does not match the AgentKit signature profile') + + const created = Number(match[1]) + const expires = Number(match[2]) + if (expires <= created) throw new Error('Signature expires must be after created') + + return { rawParams, created, expires, nonce: match[3]!, keyid: match[4]! } +} + +export function parseSignatureHeader(raw: string): Hex { + if (raw.length > MAX_HEADER_LENGTH) throw new Error('Signature header is too long') + + const match = SIGNATURE_VALUE_PATTERN.exec(raw) + if (!match) throw new Error('Signature must be a single agentkit member holding a 65-byte value') + + const bytes = decodeBase64Strict(match[1]!) + if (bytes.length !== 65) throw new Error('Signature must decode to 65 bytes') + + return bytesToHex(bytes) +} + +export function parseContentDigest(raw: string): Uint8Array { + if (raw.length > MAX_HEADER_LENGTH) throw new Error('Content-Digest header is too long') + + const match = CONTENT_DIGEST_PATTERN.exec(raw) + if (!match) throw new Error('Content-Digest must be a single sha-256 member') + + const bytes = decodeBase64Strict(match[1]!) + if (bytes.length !== 32) throw new Error('Content-Digest must decode to 32 bytes') + + return bytes +} + +export async function createSignatureHeaders(input: CreateSignatureHeadersInput): Promise { + const bodyBytes = typeof input.body === 'string' || input.body === undefined + ? new TextEncoder().encode(input.body ?? '') + : input.body + const contentDigest = computeContentDigest(bodyBytes) + const created = input.now ?? Math.floor(Date.now() / 1000) + const expires = created + (input.expiresInSeconds ?? MAX_SIGNATURE_AGE_SECONDS) + const nonce = input.nonce ?? generateNonce() + const keyid = input.address.toLowerCase() + + const signatureParams = serializeSignatureParams({ created, expires, nonce, keyid }) + const base = buildSignatureBase({ method: input.method, url: input.url, contentDigest, signatureParams }) + + const signature = await input.signMessage(base) + if (!/^0x[0-9a-fA-F]{130}$/.test(signature)) throw new Error('Signer must return a 65-byte hex signature') + + return { + 'Content-Digest': contentDigest, + 'Signature-Input': `${SIGNATURE_LABEL}=${signatureParams}`, + Signature: `${SIGNATURE_LABEL}=:${encodeBase64(hexToBytes(signature as Hex))}:`, + } +} + +function generateNonce(): string { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + return encodeBase64(bytes) +} + +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +const BASE64_INDEX = new Map([...BASE64_ALPHABET].map((char, index) => [char, index])) + +function encodeBase64(bytes: Uint8Array): string { + let output = '' + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]! + const b = bytes[i + 1] + const c = bytes[i + 2] + output += BASE64_ALPHABET[a >> 2]! + output += BASE64_ALPHABET[((a & 0x03) << 4) | ((b ?? 0) >> 4)]! + output += b === undefined ? '=' : BASE64_ALPHABET[((b & 0x0f) << 2) | ((c ?? 0) >> 6)]! + output += c === undefined ? '=' : BASE64_ALPHABET[c & 0x3f]! + } + return output +} + +function decodeBase64Strict(value: string): Uint8Array { + if (value.length === 0 || value.length % 4 !== 0) throw new Error('Invalid base64 length') + + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0 + const chars = value.slice(0, value.length - padding) + if (chars.includes('=')) throw new Error('Invalid base64 padding') + + const bytes: number[] = [] + let buffer = 0 + let bits = 0 + for (const char of chars) { + const index = BASE64_INDEX.get(char) + if (index === undefined) throw new Error('Invalid base64 character') + buffer = (buffer << 6) | index + bits += 6 + if (bits >= 8) { + bits -= 8 + bytes.push((buffer >> bits) & 0xff) + } + } + + const result = new Uint8Array(bytes) + // Round-trip guarantees canonical encoding (padding count and zeroed trailing bits). + if (encodeBase64(result) !== value) throw new Error('Non-canonical base64 encoding') + return result +} diff --git a/core/src/verify.ts b/core/src/verify.ts index 82184ed..f97a706 100644 --- a/core/src/verify.ts +++ b/core/src/verify.ts @@ -1,24 +1,58 @@ -import { isHex, recoverMessageAddress, type Hex } from 'viem' +import { isAddressEqual, recoverMessageAddress, type Hex } from 'viem' import { lookupNullifierHash } from './agent-book' +import { + CLOCK_SKEW_SECONDS, + CONTENT_DIGEST_HEADER, + MAX_SIGNATURE_AGE_SECONDS, + SIGNATURE_HEADER, + SIGNATURE_INPUT_HEADER, + buildSignatureBase, + computeContentDigest, + parseContentDigest, + parseSignatureHeader, + parseSignatureInput, +} from './signature' -const AGENTKIT_HEADER = 'X-AgentKit' +export type VerifiedAgentRequest = { + nullifierHash: string + address: string + nonce: string + created: number + expires: number +} type VerifyRequestDependencies = { - recoverAddress?: (body: Uint8Array, signature: Hex) => Promise + recoverAddress?: (signatureBase: string, signature: Hex) => Promise lookupNullifierHash?: (address: string) => Promise + /** Return false when the nonce was already seen; used to enforce single-use signatures. */ + checkNonce?: (details: { nonce: string; address: string; created: number; expires: number }) => Promise + /** Unix seconds; injectable for tests. */ + now?: () => number } export async function verify(request: Request): Promise { - return verifyRequest(request) + const { nullifierHash } = await verifyRequest(request) + return nullifierHash } -export async function verifyRequest(request: Request, dependencies: VerifyRequestDependencies = {}): Promise { - const signature = request.headers.get(AGENTKIT_HEADER)?.trim() - if (!signature) { - throw verificationError('Missing X-AgentKit header', 'MISSING_HEADER') +export async function verifyRequest( + request: Request, + dependencies: VerifyRequestDependencies = {} +): Promise { + const rawSignatureInput = requireHeader(request, SIGNATURE_INPUT_HEADER) + const rawSignature = requireHeader(request, SIGNATURE_HEADER) + const rawContentDigest = requireHeader(request, CONTENT_DIGEST_HEADER) + + const params = parseWith(parseSignatureInput, rawSignatureInput, 'INVALID_SIGNATURE_INPUT') + const signature = parseWith(parseSignatureHeader, rawSignature, 'INVALID_SIGNATURE') + parseWith(parseContentDigest, rawContentDigest, 'INVALID_CONTENT_DIGEST') + + const now = dependencies.now?.() ?? Math.floor(Date.now() / 1000) + if (params.created > now + CLOCK_SKEW_SECONDS) { + throw verificationError('Signature created timestamp is in the future', 'SIGNATURE_NOT_YET_VALID') } - if (!isHex(signature) || !/^0x[0-9a-fA-F]{130}$/.test(signature)) { - throw verificationError('Invalid X-AgentKit signature', 'INVALID_SIGNATURE') + if (now >= params.expires || now - params.created > MAX_SIGNATURE_AGE_SECONDS) { + throw verificationError('Signature has expired', 'SIGNATURE_EXPIRED') } let body: Uint8Array @@ -28,24 +62,71 @@ export async function verifyRequest(request: Request, dependencies: VerifyReques throw verificationError('Unable to read request body', 'INVALID_REQUEST_BODY') } + if (computeContentDigest(body) !== rawContentDigest) { + throw verificationError('Content-Digest does not match the request body', 'CONTENT_DIGEST_MISMATCH') + } + + // Every covered component is rebuilt from the request that actually arrived; only the + // validated signature params line is reused verbatim from the Signature-Input header. + const signatureBase = buildSignatureBase({ + method: request.method, + url: request.url, + contentDigest: rawContentDigest, + signatureParams: params.rawParams, + }) + const recoverAddress = dependencies.recoverAddress ?? - ((message: Uint8Array, value: Hex) => recoverMessageAddress({ message: { raw: message }, signature: value })) + ((message: string, value: Hex) => recoverMessageAddress({ message, signature: value })) - let address: string + let recovered: string try { - address = await recoverAddress(body, signature) + recovered = await recoverAddress(signatureBase, signature) } catch { - throw verificationError('Invalid X-AgentKit signature', 'INVALID_SIGNATURE') + throw verificationError('Invalid request signature', 'INVALID_SIGNATURE') + } + + if (!isAddressEqual(recovered as `0x${string}`, params.keyid as `0x${string}`)) { + throw verificationError('Signature does not match the keyid address', 'KEYID_MISMATCH') + } + + if (dependencies.checkNonce) { + const fresh = await dependencies.checkNonce({ + nonce: params.nonce, + address: params.keyid, + created: params.created, + expires: params.expires, + }) + if (!fresh) throw verificationError('Signature nonce has already been used', 'NONCE_REUSED', params.keyid) } const lookup = dependencies.lookupNullifierHash ?? (signer => lookupNullifierHash(signer)) - const nullifierHash = await lookup(address) + const nullifierHash = await lookup(params.keyid) if (!nullifierHash) { - throw verificationError('Agent is not registered in AgentBook', 'AGENT_NOT_REGISTERED', address) + throw verificationError('Agent is not registered in AgentBook', 'AGENT_NOT_REGISTERED', params.keyid) } - return nullifierHash + return { + nullifierHash, + address: params.keyid, + nonce: params.nonce, + created: params.created, + expires: params.expires, + } +} + +function requireHeader(request: Request, name: string): string { + const value = request.headers.get(name) + if (!value) throw verificationError(`Missing ${name} header`, 'MISSING_HEADER') + return value +} + +function parseWith(parse: (raw: string) => T, raw: string, code: string): T { + try { + return parse(raw) + } catch (error) { + throw verificationError(error instanceof Error ? error.message : 'Invalid signature header', code) + } } export function verificationError(message: string, code: string, address?: string): Error { diff --git a/core/tests/exports.test.ts b/core/tests/exports.test.ts index 0878989..cd5a25a 100644 --- a/core/tests/exports.test.ts +++ b/core/tests/exports.test.ts @@ -2,8 +2,10 @@ import * as core from '../src' import { describe, expect, it } from 'bun:test' describe('@worldcoin/agentkit-core exports', () => { - it('exports only the request verifier', () => { - expect(Object.keys(core)).toEqual(['verify']) + it('exports the request verifiers and the client signature helper', () => { + expect(Object.keys(core).sort()).toEqual(['createSignatureHeaders', 'verify', 'verifyRequest']) expect(typeof core.verify).toBe('function') + expect(typeof core.verifyRequest).toBe('function') + expect(typeof core.createSignatureHeaders).toBe('function') }) }) diff --git a/core/tests/signature.test.ts b/core/tests/signature.test.ts new file mode 100644 index 0000000..1748608 --- /dev/null +++ b/core/tests/signature.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'bun:test' +import { recoverMessageAddress } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { + buildSignatureBase, + computeContentDigest, + createSignatureHeaders, + deriveComponents, + parseContentDigest, + parseSignatureHeader, + parseSignatureInput, + serializeSignatureParams, +} from '../src/signature' + +const encoder = new TextEncoder() + +const EXAMPLE_PARAMS = { + created: 1755600000, + expires: 1755600300, + nonce: 'mAyU1DSTCXHDXqzm5g1D3A==', + keyid: '0x0123456789abcdef0123456789abcdef01234567', +} + +const EXAMPLE_RAW_PARAMS = + '("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;nonce="mAyU1DSTCXHDXqzm5g1D3A==";keyid="0x0123456789abcdef0123456789abcdef01234567";tag="agentkit"' + +describe('deriveComponents', () => { + it('normalizes method, authority, path, and query identically on both sides', () => { + expect(deriveComponents('post', 'https://API.Example.com:443/data?x=1')).toEqual({ + method: 'POST', + authority: 'api.example.com', + path: '/data', + query: '?x=1', + }) + }) + + it('keeps non-default ports in the authority', () => { + expect(deriveComponents('GET', 'https://api.example.com:8443/data').authority).toBe('api.example.com:8443') + }) + + it('serializes an absent query as a lone question mark', () => { + expect(deriveComponents('GET', 'https://api.example.com/data').query).toBe('?') + expect(deriveComponents('GET', 'https://api.example.com/data?').query).toBe('?') + }) + + it('uses the root path for a bare origin', () => { + expect(deriveComponents('GET', 'https://api.example.com').path).toBe('/') + }) +}) + +describe('computeContentDigest', () => { + it('matches the RFC 9530 sha-256 example', () => { + expect(computeContentDigest(encoder.encode('{"hello": "world"}'))).toBe( + 'sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:' + ) + }) + + it('digests the empty body', () => { + expect(computeContentDigest(new Uint8Array())).toBe('sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:') + }) +}) + +describe('buildSignatureBase', () => { + it('produces the exact profile signature base', () => { + const base = buildSignatureBase({ + method: 'POST', + url: 'https://api.example.com/data?x=1', + contentDigest: computeContentDigest(encoder.encode('{"a":1}')), + signatureParams: serializeSignatureParams(EXAMPLE_PARAMS), + }) + + expect(base).toBe( + [ + '"@method": POST', + '"@authority": api.example.com', + '"@path": /data', + '"@query": ?x=1', + '"content-digest": sha-256=:AVq9f1zFei3ZS3WQ8ErYCEJzkF7jPsXOvq5iJ2qX+GI=:', + `"@signature-params": ${EXAMPLE_RAW_PARAMS}`, + ].join('\n') + ) + }) +}) + +describe('serializeSignatureParams', () => { + it('rejects invalid inputs before they can be signed', () => { + expect(() => serializeSignatureParams({ ...EXAMPLE_PARAMS, expires: EXAMPLE_PARAMS.created })).toThrow() + expect(() => serializeSignatureParams({ ...EXAMPLE_PARAMS, created: 1.5 })).toThrow() + expect(() => serializeSignatureParams({ ...EXAMPLE_PARAMS, nonce: 'short' })).toThrow() + expect(() => serializeSignatureParams({ ...EXAMPLE_PARAMS, nonce: 'contains"quote-0123456789' })).toThrow() + expect(() => serializeSignatureParams({ ...EXAMPLE_PARAMS, nonce: 'contains\\slash-0123456789' })).toThrow() + expect(() => + serializeSignatureParams({ ...EXAMPLE_PARAMS, keyid: '0x0123456789ABCDEF0123456789abcdef01234567' }) + ).toThrow() + }) +}) + +describe('createSignatureHeaders', () => { + it('creates deterministic headers whose signature recovers the account', async () => { + const account = privateKeyToAccount(`0x${'01'.padStart(64, '0')}`) + const headers = await createSignatureHeaders({ + method: 'post', + url: 'https://api.example.com/data?x=1', + body: '{"a":1}', + address: account.address, + signMessage: message => account.signMessage({ message }), + now: EXAMPLE_PARAMS.created, + nonce: EXAMPLE_PARAMS.nonce, + }) + + expect(headers['Content-Digest']).toBe('sha-256=:AVq9f1zFei3ZS3WQ8ErYCEJzkF7jPsXOvq5iJ2qX+GI=:') + expect(headers['Signature-Input']).toBe( + `agentkit=("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;nonce="mAyU1DSTCXHDXqzm5g1D3A==";keyid="${account.address.toLowerCase()}";tag="agentkit"` + ) + + const signature = parseSignatureHeader(headers.Signature) + const parsed = parseSignatureInput(headers['Signature-Input']) + const base = buildSignatureBase({ + method: 'POST', + url: 'https://api.example.com/data?x=1', + contentDigest: headers['Content-Digest'], + signatureParams: parsed.rawParams, + }) + + expect(await recoverMessageAddress({ message: base, signature })).toBe(account.address) + }) + + it('generates a fresh nonce and current window by default', async () => { + const account = privateKeyToAccount(`0x${'02'.padStart(64, '0')}`) + const before = Math.floor(Date.now() / 1000) + const headers = await createSignatureHeaders({ + method: 'GET', + url: 'https://api.example.com/data', + address: account.address, + signMessage: message => account.signMessage({ message }), + }) + + const parsed = parseSignatureInput(headers['Signature-Input']) + expect(parsed.created).toBeGreaterThanOrEqual(before) + expect(parsed.expires).toBe(parsed.created + 300) + expect(parsed.nonce.length).toBeGreaterThanOrEqual(16) + }) +}) + +describe('parseSignatureInput', () => { + const valid = `agentkit=${EXAMPLE_RAW_PARAMS}` + + it('accepts the canonical profile value and returns the verbatim params', () => { + expect(parseSignatureInput(valid)).toEqual({ rawParams: EXAMPLE_RAW_PARAMS, ...EXAMPLE_PARAMS }) + }) + + it.each([ + ['wrong label', valid.replace('agentkit=', 'evil=')], + ['duplicate members', `${valid}, evil=("@method");created=1;expires=2;nonce="0123456789abcdef";keyid="0x0123456789abcdef0123456789abcdef01234567";tag="agentkit"`], + ['missing created', valid.replace(';created=1755600000', '')], + ['missing expires', valid.replace(';expires=1755600300', '')], + ['missing nonce', valid.replace(';nonce="mAyU1DSTCXHDXqzm5g1D3A=="', '')], + ['missing keyid', valid.replace(';keyid="0x0123456789abcdef0123456789abcdef01234567"', '')], + ['missing tag', valid.replace(';tag="agentkit"', '')], + ['extra alg param', valid.replace(';tag="agentkit"', ';tag="agentkit";alg="ed25519"')], + ['reordered params', valid.replace(';created=1755600000;expires=1755600300', ';expires=1755600300;created=1755600000')], + ['shortened components', valid.replace(' "@query"', '')], + ['extended components', valid.replace('"content-digest")', '"content-digest" "date")')], + ['reordered components', valid.replace('"@method" "@authority"', '"@authority" "@method"')], + ['negative created', valid.replace('created=1755600000', 'created=-1')], + ['non-integer created', valid.replace('created=1755600000', 'created=1755600000.5')], + ['oversized timestamp', valid.replace('created=1755600000', `created=${'9'.repeat(16)}`)], + ['expires before created', valid.replace('expires=1755600300', 'expires=1755599999')], + ['uppercase keyid', valid.replace('0x0123456789abcdef', '0x0123456789ABCDEF')], + ['short nonce', valid.replace('mAyU1DSTCXHDXqzm5g1D3A==', 'short')], + ['nonce with backslash', valid.replace('mAyU1DSTCXHDXqzm5g1D3A==', 'mAyU1DSTCXHDXqzm5g1D3\\=')], + ['wrong tag', valid.replace('tag="agentkit"', 'tag="other"')], + ['trailing garbage', `${valid};x=1`], + ['oversized header', `agentkit=${EXAMPLE_RAW_PARAMS.replace('mAyU1DSTCXHDXqzm5g1D3A==', 'a'.repeat(5000))}`], + ])('rejects %s', (_name, value) => { + expect(() => parseSignatureInput(value)).toThrow() + }) +}) + +describe('parseSignatureHeader', () => { + it('round-trips a canonical 65-byte signature', async () => { + const account = privateKeyToAccount(`0x${'03'.padStart(64, '0')}`) + const headers = await createSignatureHeaders({ + method: 'GET', + url: 'https://api.example.com/', + address: account.address, + signMessage: message => account.signMessage({ message }), + }) + + expect(parseSignatureHeader(headers.Signature)).toMatch(/^0x[0-9a-f]{130}$/) + }) + + it.each([ + ['wrong label', `evil=:${'A'.repeat(87)}=:`], + ['base64url characters', `agentkit=:${'-'.repeat(87)}=:`], + ['wrong length', `agentkit=:${'A'.repeat(43)}=:`], + ['missing padding', `agentkit=:${'A'.repeat(88)}:`], + ['bare hex', `agentkit=0x${'12'.repeat(65)}`], + ])('rejects %s', (_name, value) => { + expect(() => parseSignatureHeader(value)).toThrow() + }) +}) + +describe('parseContentDigest', () => { + it('parses the canonical sha-256 member', () => { + const digest = parseContentDigest('sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:') + expect(digest).toHaveLength(32) + }) + + it.each([ + ['different algorithm', `sha-512=:${'A'.repeat(86)}==:`], + ['multiple members', `sha-256=:${'A'.repeat(43)}=:, sha-512=:${'A'.repeat(86)}==:`], + ['non-canonical encoding', 'sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPF=:'], + ['wrong length', `sha-256=:${'A'.repeat(22)}==:`], + ])('rejects %s', (_name, value) => { + expect(() => parseContentDigest(value)).toThrow() + }) +}) diff --git a/core/tests/verify.test.ts b/core/tests/verify.test.ts index a1d9e70..1120076 100644 --- a/core/tests/verify.test.ts +++ b/core/tests/verify.test.ts @@ -1,81 +1,200 @@ import { describe, expect, it } from 'bun:test' -import { isAddressEqual } from 'viem' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { createSignatureHeaders } from '../src/signature' import { verifyRequest } from '../src/verify' -const encoder = new TextEncoder() +const NOW = 1755600000 -async function signedRequest(body: string) { +type SignedRequestOptions = { + method?: string + url?: string + body?: string + requestUrl?: string + requestMethod?: string + requestBody?: string + now?: number + expiresInSeconds?: number + keyid?: string +} + +async function signedRequest(options: SignedRequestOptions = {}) { const account = privateKeyToAccount(generatePrivateKey()) - const signature = await account.signMessage({ message: { raw: encoder.encode(body) } }) - const request = new Request('https://api.example.com/data', { - method: 'POST', - headers: { 'X-AgentKit': signature }, + const method = options.method ?? 'POST' + const url = options.url ?? 'https://api.example.com/data?x=1' + const body = options.body ?? '{"a":1}' + + const headers = await createSignatureHeaders({ + method, + url, body, + address: options.keyid ?? account.address, + signMessage: message => account.signMessage({ message }), + now: options.now ?? NOW, + expiresInSeconds: options.expiresInSeconds, + }) + + const requestMethod = options.requestMethod ?? method + const requestBody = options.requestBody ?? body + const request = new Request(options.requestUrl ?? url, { + method: requestMethod, + headers, + ...(requestMethod === 'GET' || requestMethod === 'HEAD' ? {} : { body: requestBody }), }) - return { account, request, signature } + + return { account, request, headers } +} + +function registered(account: { address: string }) { + return { + now: () => NOW + 1, + lookupNullifierHash: async (address: string) => + address === account.address.toLowerCase() ? '0x1234' : null, + } } -describe('verify', () => { - it('recovers the signer from the exact request body and returns its nullifier hash', async () => { - const body = JSON.stringify({ hello: 'world' }) - const { account, request } = await signedRequest(body) - const addresses: string[] = [] +describe('verifyRequest', () => { + it('verifies a signed POST and returns the nullifier hash, address, and params', async () => { + const { account, request } = await signedRequest() + const lookups: string[] = [] - const nullifierHash = await verifyRequest(request, { - async lookupNullifierHash(address) { - addresses.push(address) - return isAddressEqual(address as `0x${string}`, account.address) ? '0x1234' : null + const result = await verifyRequest(request, { + now: () => NOW + 1, + lookupNullifierHash: async address => { + lookups.push(address) + return address === account.address.toLowerCase() ? '0x1234' : null }, }) - expect(nullifierHash).toBe('0x1234') - expect(addresses).toHaveLength(1) - expect(isAddressEqual(addresses[0] as `0x${string}`, account.address)).toBe(true) - expect(await request.text()).toBe(body) + expect(result.nullifierHash).toBe('0x1234') + expect(result.address).toBe(account.address.toLowerCase()) + expect(result.created).toBe(NOW) + expect(result.expires).toBe(NOW + 300) + expect(result.nonce.length).toBeGreaterThanOrEqual(16) + expect(lookups).toEqual([account.address.toLowerCase()]) + expect(await request.text()).toBe('{"a":1}') }) - it('throws when the X-AgentKit header is missing', async () => { - const request = new Request('https://api.example.com/data', { method: 'POST', body: 'hello' }) - await expect(verifyRequest(request)).rejects.toThrow('Missing X-AgentKit header') + it('verifies a bodyless GET request', async () => { + const { account, request } = await signedRequest({ method: 'GET', url: 'https://api.example.com/data', body: '' }) + const result = await verifyRequest(request, registered(account)) + expect(result.nullifierHash).toBe('0x1234') }) - it('throws when the header is not a valid signature', async () => { - const request = new Request('https://api.example.com/data', { - method: 'POST', - headers: { 'X-AgentKit': 'not-a-signature' }, - body: 'hello', - }) - await expect(verifyRequest(request)).rejects.toThrow('Invalid X-AgentKit signature') + it('rejects a request missing any signature header', async () => { + for (const missing of ['Signature-Input', 'Signature', 'Content-Digest']) { + const { request } = await signedRequest() + const headers = new Headers(request.headers) + headers.delete(missing) + const stripped = new Request(request.url, { method: 'POST', headers, body: '{"a":1}' }) + await expect(verifyRequest(stripped, { now: () => NOW + 1 })).rejects.toThrow(`Missing ${missing} header`) + } }) - it('rejects a signature copied onto a different body', async () => { - const { account, signature } = await signedRequest('original') - const request = new Request('https://api.example.com/data', { + it('rejects a tampered body', async () => { + const { account, request } = await signedRequest({ requestBody: '{"a":2}' }) + await expect(verifyRequest(request, registered(account))).rejects.toThrow( + 'Content-Digest does not match the request body' + ) + }) + + it('rejects a forged digest that matches a tampered body', async () => { + const { account, headers } = await signedRequest() + const forged = await createSignatureHeaders({ + method: 'POST', + url: 'https://api.example.com/data?x=1', + body: '{"a":2}', + address: account.address, + signMessage: async () => `0x${'12'.repeat(65)}`, + now: NOW, + }) + const request = new Request('https://api.example.com/data?x=1', { method: 'POST', - headers: { 'X-AgentKit': signature }, - body: 'tampered', + headers: { ...headers, 'Content-Digest': forged['Content-Digest'] }, + body: '{"a":2}', }) + await expect(verifyRequest(request, registered(account))).rejects.toThrow(/signature|keyid/i) + }) + + it.each([ + ['method', { requestMethod: 'PUT' }], + ['path', { requestUrl: 'https://api.example.com/other?x=1' }], + ['query', { requestUrl: 'https://api.example.com/data?x=2' }], + ['removed query', { requestUrl: 'https://api.example.com/data' }], + ['authority', { requestUrl: 'https://evil.example.com/data?x=1' }], + ['port', { requestUrl: 'https://api.example.com:8443/data?x=1' }], + ])('rejects a signature replayed against a different %s', async (_name, overrides) => { + const { account, request } = await signedRequest(overrides) + await expect(verifyRequest(request, registered(account))).rejects.toThrow( + 'Signature does not match the keyid address' + ) + }) + + it('rejects an expired signature', async () => { + const { account, request } = await signedRequest({ expiresInSeconds: 10 }) + await expect(verifyRequest(request, { ...registered(account), now: () => NOW + 11 })).rejects.toThrow( + 'Signature has expired' + ) + }) + + it('rejects a long client expiry beyond the server window', async () => { + const { account, request } = await signedRequest({ expiresInSeconds: 3600 }) + await expect(verifyRequest(request, { ...registered(account), now: () => NOW + 400 })).rejects.toThrow( + 'Signature has expired' + ) + }) + + it('rejects a created timestamp too far in the future, but tolerates clock skew', async () => { + const early = await signedRequest({ now: NOW + 60 }) + await expect(verifyRequest(early.request, { ...registered(early.account), now: () => NOW })).rejects.toThrow( + 'Signature created timestamp is in the future' + ) + + const skewed = await signedRequest({ now: NOW + 3 }) + const result = await verifyRequest(skewed.request, { ...registered(skewed.account), now: () => NOW }) + expect(result.nullifierHash).toBe('0x1234') + }) + + it('rejects a valid signature whose keyid names a different address', async () => { + const other = privateKeyToAccount(generatePrivateKey()) + const { account, request } = await signedRequest({ keyid: other.address }) + await expect(verifyRequest(request, registered(account))).rejects.toThrow( + 'Signature does not match the keyid address' + ) + }) + + it('rejects a reused nonce via the checkNonce dependency', async () => { + const { account, request } = await signedRequest() + const seen: Array<{ nonce: string; address: string; created: number; expires: number }> = [] + await expect( verifyRequest(request, { - lookupNullifierHash: async address => - isAddressEqual(address as `0x${string}`, account.address) ? '0x1234' : null, + ...registered(account), + checkNonce: async details => { + seen.push(details) + return false + }, }) - ).rejects.toThrow('Agent is not registered in AgentBook') + ).rejects.toThrow('Signature nonce has already been used') + + expect(seen).toHaveLength(1) + expect(seen[0]!.address).toBe(account.address.toLowerCase()) + expect(seen[0]!.created).toBe(NOW) + expect(seen[0]!.expires).toBe(NOW + 300) }) it('throws when the recovered signer is not registered', async () => { - const { request } = await signedRequest('hello') - await expect(verifyRequest(request, { lookupNullifierHash: async () => null })).rejects.toThrow( - 'Agent is not registered in AgentBook' - ) + const { request } = await signedRequest() + await expect( + verifyRequest(request, { now: () => NOW + 1, lookupNullifierHash: async () => null }) + ).rejects.toThrow('Agent is not registered in AgentBook') }) it('propagates AgentBook RPC failures', async () => { - const { request } = await signedRequest('hello') + const { request } = await signedRequest() await expect( verifyRequest(request, { + now: () => NOW + 1, lookupNullifierHash: async () => { throw new Error('World Chain unavailable') }, From c7122900e36941cb35335939abd5ea2ccd00de3d Mon Sep 17 00:00:00 2001 From: Tom Waite Date: Wed, 19 Aug 2026 16:57:44 -0700 Subject: [PATCH 03/10] feat(x402): adopt RFC 9421 signature headers and restore nonce replay protection The hooks now verify against the request's real method and URL through a single core verifyRequest call (dropping the duplicate address recovery), and enforce single-use nonces via restored hasUsedNonce/recordNonce storage methods. The client signs the RFC 9421 signature base and retries with Signature-Input, Signature, and Content-Digest instead of X-AgentKit. Co-Authored-By: Claude Fable 5 --- x402/src/client.ts | 34 ++++++--- x402/src/hooks.ts | 61 ++++++++++----- x402/src/index.ts | 9 ++- x402/src/protocol.ts | 4 +- x402/src/storage.ts | 18 +++++ x402/tests/client-e2e.test.ts | 73 ++++++++++++++++-- x402/tests/client.test.ts | 62 +++++++++++---- x402/tests/hooks.test.ts | 140 ++++++++++++++++++++++++++++------ 8 files changed, 324 insertions(+), 77 deletions(-) diff --git a/x402/src/client.ts b/x402/src/client.ts index 92dc70e..daea7a8 100644 --- a/x402/src/client.ts +++ b/x402/src/client.ts @@ -1,7 +1,11 @@ import type { PaymentRequired } from '@x402/core/types' -import { AGENTKIT, AGENTKIT_HEADER, normalizeAgentkitBody, normalizeAgentkitRequestBody } from './protocol' +import { createSignatureHeaders, type AgentkitSignatureHeaders } from '@worldcoin/agentkit-core' +import { AGENTKIT, normalizeAgentkitBody, normalizeAgentkitRequestBody } from './protocol' export type AgentkitSigner = { + /** The agent's address; becomes the signature keyid. */ + address: string + /** EIP-191 signer over the RFC 9421 signature base. */ signMessage(message: string): Promise } @@ -19,14 +23,20 @@ export interface CreateAgentkitClientOptions { export interface AgentkitClient { fetch: typeof fetch - createHeader(body: unknown): Promise + createHeaders(input: { method: string; url: string | URL; body?: unknown }): Promise } export function createAgentkitClient(options: CreateAgentkitClientOptions): AgentkitClient { const fetchFn = options.fetch ?? globalThis.fetch - const createHeader = async (body: unknown): Promise => - options.signer.signMessage(normalizeAgentkitBody(body)) + const createHeaders = (input: { method: string; url: string | URL; body?: unknown }) => + createSignatureHeaders({ + method: input.method, + url: input.url, + body: normalizeAgentkitBody(input.body), + address: options.signer.address, + signMessage: message => options.signer.signMessage(message), + }) const agentkitFetch = (async ( input: Parameters[0], @@ -42,16 +52,22 @@ export function createAgentkitClient(options: CreateAgentkitClientOptions): Agen const url = request.url options.onEvent?.({ type: 'agentkit_detected', url }) - let header: string + let signatureHeaders: AgentkitSignatureHeaders let body: string try { body = await normalizeAgentkitRequestBody(request) - header = await createHeader(body) + signatureHeaders = await createSignatureHeaders({ + method: request.method, + url: request.url, + body, + address: options.signer.address, + signMessage: message => options.signer.signMessage(message), + }) } catch (err) { options.onEvent?.({ type: 'agentkit_skipped', url, - reason: err instanceof Error ? err.message : 'Unable to create AgentKit header', + reason: err instanceof Error ? err.message : 'Unable to create AgentKit signature', }) return response } @@ -59,7 +75,7 @@ export function createAgentkitClient(options: CreateAgentkitClientOptions): Agen options.onEvent?.({ type: 'agentkit_signed', url }) const headers = new Headers(request.headers) - headers.set(AGENTKIT_HEADER, header) + for (const [name, value] of Object.entries(signatureHeaders)) headers.set(name, value) headers.delete('content-length') const retryResponse = await fetchFn(createRetryRequest(request, headers, body)) @@ -70,7 +86,7 @@ export function createAgentkitClient(options: CreateAgentkitClientOptions): Agen return { fetch: agentkitFetch, - createHeader, + createHeaders, } } diff --git a/x402/src/hooks.ts b/x402/src/hooks.ts index fa67e12..0b3f11c 100644 --- a/x402/src/hooks.ts +++ b/x402/src/hooks.ts @@ -1,8 +1,13 @@ import type { AgentkitMode } from './types' import type { AgentKitStorage } from './storage' -import { verify } from '@worldcoin/agentkit-core' -import { recoverMessageAddress, type Hex } from 'viem' -import { AGENTKIT_HEADER, normalizeAgentkitBody, normalizeAgentkitJsonBody } from './protocol' +import { verifyRequest, type VerifiedAgentRequest } from '@worldcoin/agentkit-core' +import { + AGENTKIT_CONTENT_DIGEST_HEADER, + AGENTKIT_SIGNATURE_HEADER, + AGENTKIT_SIGNATURE_INPUT_HEADER, + normalizeAgentkitBody, + normalizeAgentkitJsonBody, +} from './protocol' export type AgentkitHookEvent = | { type: 'agent_verified'; resource: string; address: string; humanId: string } @@ -21,18 +26,14 @@ export function createAgentkitHooks(options: CreateAgentkitHooksOptions) { return createAgentkitHooksInternal(options) } -type VerifyFunction = (request: Request) => Promise -type RecoverAddressFunction = (body: Uint8Array, signature: Hex) => Promise +type VerifyFunction = (request: Request) => Promise export function createAgentkitHooksInternal( options: CreateAgentkitHooksOptions, - dependencies: { verify?: VerifyFunction; recoverAddress?: RecoverAddressFunction } = {} + dependencies: { verify?: VerifyFunction } = {} ) { const { onEvent } = options - const verifyRequest = dependencies.verify ?? verify - const recoverAddress = - dependencies.recoverAddress ?? - ((body: Uint8Array, signature: Hex) => recoverMessageAddress({ message: { raw: body }, signature })) + const verify = dependencies.verify ?? verifyRequest const mode: AgentkitMode = options.mode ?? { type: 'free' } const storage = options.storage @@ -58,11 +59,16 @@ export function createAgentkitHooksInternal( const pendingDiscounts = new Map() const requestHook = async (context: { - adapter: { getHeader(name: string): string | undefined; getUrl(): string; getBody?(): unknown } + adapter: { + getHeader(name: string): string | undefined + getMethod(): string + getUrl(): string + getBody?(): unknown + } path: string }): Promise => { - const header = context.adapter.getHeader(AGENTKIT_HEADER) - if (!header) return + const signatureInput = context.adapter.getHeader(AGENTKIT_SIGNATURE_INPUT_HEADER) + if (!signatureInput) return try { const parsedBody = await context.adapter.getBody?.() @@ -71,14 +77,31 @@ export function createAgentkitHooksInternal( contentType === 'application/json' || contentType?.endsWith('+json') ? normalizeAgentkitJsonBody(parsedBody) : normalizeAgentkitBody(parsedBody) - const bodyBytes = new TextEncoder().encode(body) + + // Rebuild the request core verifies against from what actually arrived: the real + // method and URL, the signature headers, and the re-normalized body bytes. + const method = context.adapter.getMethod().toUpperCase() + const headers = new Headers({ [AGENTKIT_SIGNATURE_INPUT_HEADER]: signatureInput }) + const signatureHeader = context.adapter.getHeader(AGENTKIT_SIGNATURE_HEADER) + if (signatureHeader) headers.set(AGENTKIT_SIGNATURE_HEADER, signatureHeader) + const contentDigest = context.adapter.getHeader(AGENTKIT_CONTENT_DIGEST_HEADER) + if (contentDigest) headers.set(AGENTKIT_CONTENT_DIGEST_HEADER, contentDigest) + const verificationRequest = new Request(context.adapter.getUrl(), { - method: 'POST', - headers: { [AGENTKIT_HEADER]: header }, - body, + method, + headers, + // GET/HEAD requests cannot carry a body; core digests the empty byte string. + ...(method === 'GET' || method === 'HEAD' ? {} : { body }), }) - const humanId = await verifyRequest(verificationRequest) - const address = await recoverAddress(bodyBytes, header as Hex) + const { nullifierHash: humanId, address, nonce } = await verify(verificationRequest) + + if (storage?.hasUsedNonce && storage?.recordNonce) { + if (await storage.hasUsedNonce(nonce)) { + onEvent?.({ type: 'validation_failed', resource: context.path, error: 'Signature nonce already used' }) + return + } + await storage.recordNonce(nonce) + } if (mode.type === 'free') { onEvent?.({ type: 'agent_verified', resource: context.path, address, humanId }) diff --git a/x402/src/index.ts b/x402/src/index.ts index 3a401ef..54bea82 100644 --- a/x402/src/index.ts +++ b/x402/src/index.ts @@ -1,5 +1,12 @@ // x402 protocol -export { AGENTKIT, AGENTKIT_HEADER, normalizeAgentkitBody, normalizeAgentkitRequestBody } from './protocol' +export { + AGENTKIT, + AGENTKIT_CONTENT_DIGEST_HEADER, + AGENTKIT_SIGNATURE_HEADER, + AGENTKIT_SIGNATURE_INPUT_HEADER, + normalizeAgentkitBody, + normalizeAgentkitRequestBody, +} from './protocol' export type { AgentkitExtension } from './protocol' // x402-specific types diff --git a/x402/src/protocol.ts b/x402/src/protocol.ts index a18ab85..1cbce6a 100644 --- a/x402/src/protocol.ts +++ b/x402/src/protocol.ts @@ -1,7 +1,9 @@ import type { AgentkitMode } from './types' export const AGENTKIT = 'agentkit' -export const AGENTKIT_HEADER = 'X-AgentKit' +export const AGENTKIT_SIGNATURE_INPUT_HEADER = 'Signature-Input' +export const AGENTKIT_SIGNATURE_HEADER = 'Signature' +export const AGENTKIT_CONTENT_DIGEST_HEADER = 'Content-Digest' export interface AgentkitExtension { mode?: AgentkitMode diff --git a/x402/src/storage.ts b/x402/src/storage.ts index 779cf1b..82e6061 100644 --- a/x402/src/storage.ts +++ b/x402/src/storage.ts @@ -7,10 +7,20 @@ export interface AgentKitStorage { * (e.g. a database transaction with row-level locking) to prevent TOCTOU race conditions. */ tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise + + /** + * Optional replay protection: when both nonce methods are implemented, the hooks reject + * any signature whose nonce was already recorded. Production implementations should make + * check-and-record atomic (e.g. Redis SET NX EX) and expire entries with a TTL of at + * least the 300-second signature window. + */ + hasUsedNonce?(nonce: string): Promise + recordNonce?(nonce: string): Promise } export class InMemoryAgentKitStorage implements AgentKitStorage { private usage = new Map() + private nonces = new Set() async tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise { const key = `${endpoint}:${humanId}` @@ -19,4 +29,12 @@ export class InMemoryAgentKitStorage implements AgentKitStorage { this.usage.set(key, count + 1) return true } + + async hasUsedNonce(nonce: string): Promise { + return this.nonces.has(nonce) + } + + async recordNonce(nonce: string): Promise { + this.nonces.add(nonce) + } } diff --git a/x402/tests/client-e2e.test.ts b/x402/tests/client-e2e.test.ts index 4ba4a8e..55d6fd6 100644 --- a/x402/tests/client-e2e.test.ts +++ b/x402/tests/client-e2e.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from 'bun:test' +import { createSignatureHeaders } from '../../core/src/signature' import { verifyRequest } from '../../core/src/verify' import { createAgentkitHooksInternal } from '../src/hooks' -import { AGENTKIT, AGENTKIT_HEADER } from '../src/protocol' +import { InMemoryAgentKitStorage } from '../src/storage' import { createAgentkitClient, type AgentKitStorage } from '../src' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { + AGENTKIT, + AGENTKIT_CONTENT_DIGEST_HEADER, + AGENTKIT_SIGNATURE_HEADER, + AGENTKIT_SIGNATURE_INPUT_HEADER, +} from '../src/protocol' const CHAIN_ID = 'eip155:8453' const PROTECTED_URL = 'https://agentkit.example/protected' @@ -37,6 +44,9 @@ function createAdapter(request: Request, body: unknown) { if (name.toLowerCase() === 'content-type') return request.headers.get('content-type') ?? undefined return request.headers.get(name) ?? undefined }, + getMethod() { + return request.method + }, getUrl() { return request.url }, @@ -47,7 +57,7 @@ function createAdapter(request: Request, body: unknown) { } describe('AgentKit client/server E2E', () => { - it('signs the request body and satisfies an AgentKit-enabled 402 before payment', async () => { + it('signs the request and satisfies an AgentKit-enabled 402 before payment', async () => { const account = privateKeyToAccount(generatePrivateKey()) const clientEvents: Array> = [] const serverEvents: Array> = [] @@ -72,7 +82,7 @@ describe('AgentKit client/server E2E', () => { verifyRequest(request, { async lookupNullifierHash(address) { lookups.push(address) - return address.toLowerCase() === account.address.toLowerCase() ? 'human-1' : null + return address === account.address.toLowerCase() ? 'human-1' : null }, }), } @@ -91,7 +101,7 @@ describe('AgentKit client/server E2E', () => { } const agentkit = createAgentkitClient({ - signer: { signMessage: message => account.signMessage({ message }) }, + signer: { address: account.address, signMessage: message => account.signMessage({ message }) }, fetch, onEvent: event => clientEvents.push(event), }) @@ -106,7 +116,7 @@ describe('AgentKit client/server E2E', () => { expect(response.status).toBe(200) expect(body).toEqual({ ok: true }) expect(requestCount).toBe(2) - expect(lookups).toEqual([account.address]) + expect(lookups).toEqual([account.address.toLowerCase()]) expect(usageCalls).toEqual([{ endpoint: '/protected', humanId: 'human-1', limit: 3 }]) expect(clientEvents.map(event => event.type)).toEqual([ 'agentkit_detected', @@ -117,14 +127,61 @@ describe('AgentKit client/server E2E', () => { { type: 'agent_verified', resource: '/protected', - address: account.address, + address: account.address.toLowerCase(), humanId: 'human-1', }, ]) }) - it('does not treat the lowercase extension key as the request header name', () => { + it('rejects an identical replay and a tampered body under the same signature', async () => { + const account = privateKeyToAccount(generatePrivateKey()) + const events: Array> = [] + const hooks = createAgentkitHooksInternal( + { storage: new InMemoryAgentKitStorage(), onEvent: event => events.push(event as Record) }, + { verify: request => verifyRequest(request, { lookupNullifierHash: async () => 'human-1' }) } + ) + + const headers = await createSignatureHeaders({ + method: 'POST', + url: PROTECTED_URL, + body: '{"hello":"world"}', + address: account.address, + signMessage: message => account.signMessage({ message }), + }) + + const adapter = (body: unknown) => ({ + getHeader(name: string) { + const lower = name.toLowerCase() + if (lower === 'content-type') return 'application/json' + if (lower === 'signature-input') return headers['Signature-Input'] + if (lower === 'signature') return headers.Signature + if (lower === 'content-digest') return headers['Content-Digest'] + return undefined + }, + getMethod: () => 'POST', + getUrl: () => PROTECTED_URL, + getBody: () => body, + }) + + await expect(hooks.requestHook({ adapter: adapter({ hello: 'world' }), path: '/protected' })).resolves.toEqual({ + grantAccess: true, + }) + await expect( + hooks.requestHook({ adapter: adapter({ hello: 'world' }), path: '/protected' }) + ).resolves.toBeUndefined() + await expect( + hooks.requestHook({ adapter: adapter({ hello: 'tampered' }), path: '/protected' }) + ).resolves.toBeUndefined() + + expect(events.map(event => event.type)).toEqual(['agent_verified', 'validation_failed', 'validation_failed']) + expect(events[1]!.error).toBe('Signature nonce already used') + expect(events[2]!.error).toBe('Content-Digest does not match the request body') + }) + + it('uses standard signature header names distinct from the extension key', () => { expect(AGENTKIT).toBe('agentkit') - expect(AGENTKIT_HEADER).toBe('X-AgentKit') + expect(AGENTKIT_SIGNATURE_INPUT_HEADER).toBe('Signature-Input') + expect(AGENTKIT_SIGNATURE_HEADER).toBe('Signature') + expect(AGENTKIT_CONTENT_DIGEST_HEADER).toBe('Content-Digest') }) }) diff --git a/x402/tests/client.test.ts b/x402/tests/client.test.ts index 6d3c541..13b385d 100644 --- a/x402/tests/client.test.ts +++ b/x402/tests/client.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from 'bun:test' -import { AGENTKIT_HEADER, normalizeAgentkitBody } from '../src/protocol' +import { AGENTKIT_SIGNATURE_INPUT_HEADER } from '../src/protocol' import { createAgentkitClient, type AgentkitSigner } from '../src/client' const CHAIN_ID = 'eip155:8453' const SIGNATURE = `0x${'12'.repeat(65)}` +const ADDRESS = '0x1234567890AbcdEF1234567890aBcdef12345678' function createSigner(): AgentkitSigner & { messages: string[] } { const messages: string[] = [] return { + address: ADDRESS, messages, async signMessage(message: string) { messages.push(message) @@ -40,13 +42,30 @@ function paymentRequired(agentkit = true) { } describe('createAgentkitClient', () => { - it('creates an X-AgentKit value by signing the normalized body', async () => { + it('creates RFC 9421 headers by signing the signature base for the request', async () => { const signer = createSigner() const agentkit = createAgentkitClient({ signer }) const body = { hello: 'world', unicode: '你好' } - await expect(agentkit.createHeader(body)).resolves.toBe(SIGNATURE) - expect(signer.messages).toEqual([normalizeAgentkitBody(body)]) + const headers = await agentkit.createHeaders({ + method: 'POST', + url: 'https://agentkit.example/protected', + body, + }) + + expect(headers['Content-Digest']).toMatch(/^sha-256=:[A-Za-z0-9+/]{43}=:$/) + expect(headers['Signature-Input']).toMatch( + new RegExp( + `^agentkit=\\("@method" "@authority" "@path" "@query" "content-digest"\\);created=\\d+;expires=\\d+;nonce="[^"]+";keyid="${ADDRESS.toLowerCase()}";tag="agentkit"$` + ) + ) + expect(headers.Signature).toMatch(/^agentkit=:[A-Za-z0-9+/]{87}=:$/) + expect(signer.messages).toHaveLength(1) + expect( + signer.messages[0]!.startsWith( + '"@method": POST\n"@authority": agentkit.example\n"@path": /protected\n"@query": ?\n' + ) + ).toBe(true) }) it('preserves the JSON representation of primitive string bodies', async () => { @@ -55,7 +74,7 @@ describe('createAgentkitClient', () => { signer, fetch: async request => { const req = request instanceof Request ? request : new Request(request) - return req.headers.has(AGENTKIT_HEADER) + return req.headers.has(AGENTKIT_SIGNATURE_INPUT_HEADER) ? new Response('ok') : Response.json(paymentRequired(), { status: 402 }) }, @@ -67,21 +86,28 @@ describe('createAgentkitClient', () => { body: '"hello"', }) - expect(signer.messages).toEqual(['"hello"']) + expect(signer.messages).toHaveLength(1) + expect(signer.messages[0]).toContain('sha-256=:') + expect(signer.messages[0]).toContain('"@method": POST') }) it('normalizes JSON once and retries with the exact body that was signed', async () => { const signer = createSigner() const events: Array> = [] - const retries: Array<{ header: string | null; body: string }> = [] + const retries: Array<{ signatureInput: string | null; signature: string | null; digest: string | null; body: string }> = + [] const agentkit = createAgentkitClient({ signer, onEvent: event => events.push(event), fetch: async request => { const req = request instanceof Request ? request : new Request(request) - const header = req.headers.get(AGENTKIT_HEADER) - if (header) { - retries.push({ header, body: await req.text() }) + if (req.headers.has(AGENTKIT_SIGNATURE_INPUT_HEADER)) { + retries.push({ + signatureInput: req.headers.get('Signature-Input'), + signature: req.headers.get('Signature'), + digest: req.headers.get('Content-Digest'), + body: await req.text(), + }) return Response.json({ ok: true }) } @@ -96,8 +122,13 @@ describe('createAgentkitClient', () => { }) expect(response.status).toBe(200) - expect(signer.messages).toEqual(['{"hello":"world","count":2}']) - expect(retries).toEqual([{ header: SIGNATURE, body: '{"hello":"world","count":2}' }]) + expect(retries).toHaveLength(1) + expect(retries[0]!.body).toBe('{"hello":"world","count":2}') + expect(retries[0]!.signatureInput).toMatch(/^agentkit=\(/) + expect(retries[0]!.signature).toMatch(/^agentkit=:[A-Za-z0-9+/]{87}=:$/) + expect(retries[0]!.digest).toMatch(/^sha-256=:[A-Za-z0-9+/]{43}=:$/) + expect(signer.messages).toHaveLength(1) + expect(signer.messages[0]).toContain(retries[0]!.digest!) expect(events.map(event => event.type)).toEqual([ 'agentkit_detected', 'agentkit_signed', @@ -111,14 +142,16 @@ describe('createAgentkitClient', () => { signer, fetch: async request => { const req = request instanceof Request ? request : new Request(request) - return req.headers.has(AGENTKIT_HEADER) + return req.headers.has(AGENTKIT_SIGNATURE_INPUT_HEADER) ? new Response('ok') : Response.json(paymentRequired(), { status: 402 }) }, }) await expect(agentkit.fetch('https://agentkit.example/protected')).resolves.toHaveProperty('status', 200) - expect(signer.messages).toEqual(['']) + expect(signer.messages).toHaveLength(1) + expect(signer.messages[0]).toContain('"@method": GET') + expect(signer.messages[0]).toContain('sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:') }) it('returns successful non-402 responses unchanged', async () => { @@ -140,6 +173,7 @@ describe('createAgentkitClient', () => { const original = Response.json(paymentRequired(), { status: 402 }) const agentkit = createAgentkitClient({ signer: { + address: ADDRESS, async signMessage() { throw new Error('signer unavailable') }, diff --git a/x402/tests/hooks.test.ts b/x402/tests/hooks.test.ts index 41e47ce..a6c7130 100644 --- a/x402/tests/hooks.test.ts +++ b/x402/tests/hooks.test.ts @@ -1,59 +1,108 @@ -import type { Hex } from 'viem' import { describe, expect, it } from 'bun:test' -import { AGENTKIT_HEADER } from '../src/protocol' -import type { AgentKitStorage } from '../src/storage' +import type { VerifiedAgentRequest } from '@worldcoin/agentkit-core' +import { AgentKitStorage, InMemoryAgentKitStorage } from '../src/storage' import { createAgentkitHooksInternal } from '../src/hooks' const ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' -const SIGNATURE = `0x${'12'.repeat(65)}` -const URL = 'https://agentkit.example/protected' +const URL_ = 'https://agentkit.example/protected' +const SIGNATURE_INPUT = + 'agentkit=("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;nonce="mAyU1DSTCXHDXqzm5g1D3A==";keyid="0x1234567890abcdef1234567890abcdef12345678";tag="agentkit"' +const SIGNATURE = `agentkit=:${'A'.repeat(87)}=:` +const CONTENT_DIGEST = 'sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:' + +const VERIFIED: VerifiedAgentRequest = { + nullifierHash: 'human-1', + address: ADDRESS, + nonce: 'mAyU1DSTCXHDXqzm5g1D3A==', + created: 1755600000, + expires: 1755600300, +} + +type AdapterOptions = { + body?: unknown + contentType?: string + method?: string +} + +function createAdapter(options: AdapterOptions = {}) { + const headers: Record = { + 'signature-input': SIGNATURE_INPUT, + signature: SIGNATURE, + 'content-digest': CONTENT_DIGEST, + 'content-type': options.contentType, + } -function createAdapter(body: unknown = { hello: 'world' }, header = SIGNATURE, contentType?: string) { return { getHeader(name: string) { - if (name.toLowerCase() === AGENTKIT_HEADER.toLowerCase()) return header - if (name.toLowerCase() === 'content-type') return contentType - return undefined + return headers[name.toLowerCase()] + }, + getMethod() { + return options.method ?? 'POST' }, getUrl() { - return URL + return URL_ }, getBody() { - return body + return 'body' in options ? options.body : { hello: 'world' } }, } } -const dependencies = { - verify: async () => 'human-1', - recoverAddress: async (_body: Uint8Array, _signature: Hex) => ADDRESS, -} +const dependencies = { verify: async () => VERIFIED } describe('createAgentkitHooks', () => { - it('passes the normalized adapter body and X-AgentKit header to core verify', async () => { + it('passes the real method, URL, signature headers, and normalized body to core verify', async () => { const requests: Request[] = [] const hooks = createAgentkitHooksInternal( {}, { - ...dependencies, verify: async request => { requests.push(request) - return 'human-1' + return VERIFIED }, } ) await expect( - hooks.requestHook({ - adapter: createAdapter({ hello: 'world' }, SIGNATURE, 'application/json'), - path: '/protected', - }) + hooks.requestHook({ adapter: createAdapter({ contentType: 'application/json' }), path: '/protected' }) ).resolves.toEqual({ grantAccess: true }) + expect(requests).toHaveLength(1) - expect(requests[0]!.headers.get(AGENTKIT_HEADER)).toBe(SIGNATURE) + expect(requests[0]!.method).toBe('POST') + expect(requests[0]!.url).toBe(URL_) + expect(requests[0]!.headers.get('Signature-Input')).toBe(SIGNATURE_INPUT) + expect(requests[0]!.headers.get('Signature')).toBe(SIGNATURE) + expect(requests[0]!.headers.get('Content-Digest')).toBe(CONTENT_DIGEST) expect(await requests[0]!.text()).toBe('{"hello":"world"}') }) + it('builds a bodyless verification request for GET without throwing', async () => { + const requests: Request[] = [] + const hooks = createAgentkitHooksInternal( + {}, + { + verify: async request => { + requests.push(request) + return VERIFIED + }, + } + ) + + await expect( + hooks.requestHook({ adapter: createAdapter({ method: 'GET', body: undefined }), path: '/protected' }) + ).resolves.toEqual({ grantAccess: true }) + + expect(requests[0]!.method).toBe('GET') + expect(requests[0]!.body).toBeNull() + }) + + it('ignores requests without a Signature-Input header', async () => { + const hooks = createAgentkitHooksInternal({}, dependencies) + const adapter = { ...createAdapter(), getHeader: () => undefined } + + await expect(hooks.requestHook({ adapter, path: '/protected' })).resolves.toBeUndefined() + }) + it('uses the nullifier hash to grant free-trial access', async () => { const usageCalls: Array<{ endpoint: string; humanId: string; limit: number }> = [] const events: Array> = [] @@ -87,6 +136,48 @@ describe('createAgentkitHooks', () => { ]) }) + it('rejects a replayed nonce when storage tracks nonces', async () => { + const events: Array> = [] + const hooks = createAgentkitHooksInternal( + { storage: new InMemoryAgentKitStorage(), onEvent: event => events.push(event as Record) }, + dependencies + ) + + await expect(hooks.requestHook({ adapter: createAdapter(), path: '/protected' })).resolves.toEqual({ + grantAccess: true, + }) + await expect(hooks.requestHook({ adapter: createAdapter(), path: '/protected' })).resolves.toBeUndefined() + + expect(events.filter(event => event.type === 'validation_failed')).toEqual([ + { type: 'validation_failed', resource: '/protected', error: 'Signature nonce already used' }, + ]) + }) + + it('records the verified nonce exactly once on success', async () => { + const recorded: string[] = [] + const storage: AgentKitStorage = { + tryIncrementUsage: async () => true, + hasUsedNonce: async () => false, + recordNonce: async nonce => { + recorded.push(nonce) + }, + } + const hooks = createAgentkitHooksInternal({ storage }, dependencies) + + await hooks.requestHook({ adapter: createAdapter(), path: '/protected' }) + + expect(recorded).toEqual([VERIFIED.nonce]) + }) + + it('still grants access when storage does not implement nonce tracking', async () => { + const storage: AgentKitStorage = { tryIncrementUsage: async () => true } + const hooks = createAgentkitHooksInternal({ mode: { type: 'free-trial', uses: 3 }, storage }, dependencies) + + await expect(hooks.requestHook({ adapter: createAdapter(), path: '/protected' })).resolves.toEqual({ + grantAccess: true, + }) + }) + it('uses the nullifier hash to recover discounted underpayments', async () => { const usageCalls: Array<{ endpoint: string; humanId: string; limit: number }> = [] const events: Array> = [] @@ -110,7 +201,7 @@ describe('createAgentkitHooks', () => { const requirements = { amount: '100' } const verifyResult = await hooks.verifyFailureHook?.({ paymentPayload: { - resource: { url: URL }, + resource: { url: URL_ }, payload: { authorization: { from: ADDRESS, value: '50' } }, }, requirements, @@ -136,7 +227,6 @@ describe('createAgentkitHooks', () => { const hooks = createAgentkitHooksInternal( { onEvent: event => events.push(event as Record) }, { - ...dependencies, verify: async () => { throw Object.assign(new Error('Agent is not registered in AgentBook'), { code: 'AGENT_NOT_REGISTERED', From 69697c7f0b953a704cbcd907ef47baa6081e46c2 Mon Sep 17 00:00:00 2001 From: Tom Waite Date: Wed, 19 Aug 2026 16:59:41 -0700 Subject: [PATCH 04/10] feat(cli): prove signs method, URL, and body via RFC 9421 headers prove now takes [body] and returns the Content-Digest, Signature-Input, and Signature header values instead of a bare EIP-191 body signature, using the shared profile implementation from @worldcoin/agentkit-core. Co-Authored-By: Claude Fable 5 --- bun.lock | 1 + cli/package.json | 1 + cli/src/index.ts | 27 +++++++++++---- cli/src/prove.ts | 34 +++++++++++++++---- cli/test/prove.test.ts | 77 +++++++++++++++++++++++++++++++++--------- 5 files changed, 112 insertions(+), 28 deletions(-) diff --git a/bun.lock b/bun.lock index 1fe8333..069378d 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "agentkit": "dist/index.js", }, "dependencies": { + "@worldcoin/agentkit-core": "^0.2.1", "@worldcoin/idkit-core": "2.1.0", "incur": "^0.2.2", "qrcode-terminal": "^0.12.0", diff --git a/cli/package.json b/cli/package.json index 4bba804..457b268 100644 --- a/cli/package.json +++ b/cli/package.json @@ -25,6 +25,7 @@ "test": "bun run --cwd ../core build && bun test" }, "dependencies": { + "@worldcoin/agentkit-core": "^0.2.1", "@worldcoin/idkit-core": "2.1.0", "incur": "^0.2.2", "qrcode-terminal": "^0.12.0", diff --git a/cli/src/index.ts b/cli/src/index.ts index 753a890..53e0e57 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -8,7 +8,7 @@ import type { ISuccessResult } from '@worldcoin/idkit-core' import { createWorldBridgeStore } from '@worldcoin/idkit-core' import { solidityEncode } from '@worldcoin/idkit-core/hashing' import { createPublicClient, http, decodeAbiParameters } from 'viem' -import { requestBodyInputSchema, signRequestBody } from './prove.js' +import { bodyInputSchema, createProofHeaders, methodInputSchema, urlInputSchema } from './prove.js' import { loadAgentSigner, loadOrCreateAgentIdentity } from './key.js' // ─── Config ────────────────────────────────────────────────────────────────── @@ -253,12 +253,20 @@ cli.command('register', { }) cli.command('prove', { - description: 'Sign a request body with this registered agent.', + description: 'Sign a request with this registered agent using RFC 9421 HTTP message signatures.', args: z.object({ - body: requestBodyInputSchema, + method: methodInputSchema, + url: urlInputSchema, + body: bodyInputSchema, }), output: z.object({ - signature: z.string().describe('Hexadecimal X-AgentKit signature'), + headers: z + .object({ + 'Content-Digest': z.string().describe('Digest of the request body'), + 'Signature-Input': z.string().describe('RFC 9421 signature parameters'), + Signature: z.string().describe('RFC 9421 signature'), + }) + .describe('Copy these headers onto the request unmodified'), }), async run(c) { let signer @@ -301,11 +309,18 @@ cli.command('prove', { } try { - return { signature: await signRequestBody(c.args.body, signer) } + return { + headers: await createProofHeaders({ + method: c.args.method, + url: c.args.url, + body: c.args.body, + signer, + }), + } } catch (err) { return c.error({ code: 'SIGNING_FAILED', - message: err instanceof Error ? err.message : 'Unable to sign the request body', + message: err instanceof Error ? err.message : 'Unable to sign the request', }) } }, diff --git a/cli/src/prove.ts b/cli/src/prove.ts index 0279ed8..b1d581c 100644 --- a/cli/src/prove.ts +++ b/cli/src/prove.ts @@ -1,11 +1,33 @@ import { z } from 'incur' +import { createSignatureHeaders, type AgentkitSignatureHeaders } from '@worldcoin/agentkit-core' +import type { AgentSigner } from './key.js' -export const requestBodyInputSchema = z.string().describe('Exact UTF-8 request body to sign') +export const methodInputSchema = z + .string() + .regex(/^[A-Za-z]+$/, 'Invalid HTTP method') + .describe('HTTP method of the request, e.g. GET or POST') -export type MessageSigner = { - signMessage: (message: string) => Promise<`0x${string}`> -} +export const urlInputSchema = z + .string() + .regex(/^https?:\/\/\S+$/, 'Invalid request URL') + .describe('Full request URL, including any query string') + +export const bodyInputSchema = z + .string() + .default('') + .describe('Exact UTF-8 request body; omit for bodyless requests') -export function signRequestBody(body: string, signer: MessageSigner): Promise<`0x${string}`> { - return signer.signMessage(body) +export function createProofHeaders(input: { + method: string + url: string + body: string + signer: AgentSigner +}): Promise { + return createSignatureHeaders({ + method: input.method, + url: input.url, + body: input.body, + address: input.signer.address, + signMessage: message => input.signer.signMessage(message), + }) } diff --git a/cli/test/prove.test.ts b/cli/test/prove.test.ts index 38a1001..4a90b47 100644 --- a/cli/test/prove.test.ts +++ b/cli/test/prove.test.ts @@ -1,26 +1,71 @@ -import { verifyMessage } from 'viem' -import { describe, expect, test } from 'bun:test' -import { signRequestBody } from '../src/prove.js' +import { describe, expect, it } from 'bun:test' +import { verifyRequest } from '@worldcoin/agentkit-core' import { privateKeyToAccount } from 'viem/accounts' +import { createProofHeaders } from '../src/prove.js' +import type { AgentSigner } from '../src/key.js' -describe('request body proof', () => { - test('returns a raw EIP-191 signature over the exact body', async () => { - const account = privateKeyToAccount(`0x${'01'.padStart(64, '0')}`) - const body = '{"hello":"world","unicode":"你好"}' - const signature = await signRequestBody(body, { - signMessage: message => account.signMessage({ message }), +function createSigner(privateKey: `0x${string}`): AgentSigner & { account: ReturnType } { + const account = privateKeyToAccount(privateKey) + return { + account, + address: account.address, + signMessage: message => account.signMessage({ message }), + } +} + +function registeredLookup(signer: { address: string }) { + return async (address: string) => (address === signer.address.toLowerCase() ? '0x1234' : null) +} + +describe('createProofHeaders', () => { + it('produces headers that pass core verification for the same request', async () => { + const signer = createSigner(`0x${'01'.padStart(64, '0')}`) + const body = '{"a":1}' + const headers = await createProofHeaders({ + method: 'post', + url: 'https://api.example.com/data?x=1', + body, + signer, }) - expect(signature).toMatch(/^0x[0-9a-f]{130}$/) - expect(await verifyMessage({ address: account.address, message: body, signature })).toBe(true) + const request = new Request('https://api.example.com/data?x=1', { method: 'POST', headers, body }) + const result = await verifyRequest(request, { lookupNullifierHash: registeredLookup(signer) }) + + expect(result.nullifierHash).toBe('0x1234') + expect(result.address).toBe(signer.address.toLowerCase()) + expect(result.nonce.length).toBeGreaterThanOrEqual(16) + }) + + it('signs bodyless GET requests with an empty-body digest', async () => { + const signer = createSigner(`0x${'02'.padStart(64, '0')}`) + const headers = await createProofHeaders({ + method: 'GET', + url: 'https://api.example.com/data', + body: '', + signer, + }) + + expect(headers['Content-Digest']).toBe('sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:') + + const request = new Request('https://api.example.com/data', { method: 'GET', headers }) + const result = await verifyRequest(request, { lookupNullifierHash: registeredLookup(signer) }) + + expect(result.nullifierHash).toBe('0x1234') }) - test('supports the empty body used by GET requests', async () => { - const account = privateKeyToAccount(`0x${'02'.padStart(64, '0')}`) - const signature = await signRequestBody('', { - signMessage: message => account.signMessage({ message }), + it('rejects headers replayed against a different URL', async () => { + const signer = createSigner(`0x${'03'.padStart(64, '0')}`) + const body = '{"a":1}' + const headers = await createProofHeaders({ + method: 'POST', + url: 'https://api.example.com/data', + body, + signer, }) - expect(await verifyMessage({ address: account.address, message: '', signature })).toBe(true) + const request = new Request('https://api.example.com/other', { method: 'POST', headers, body }) + await expect(verifyRequest(request, { lookupNullifierHash: registeredLookup(signer) })).rejects.toThrow( + 'Signature does not match the keyid address' + ) }) }) From 7513eaef43d9f114c0c84fbdd665faae6785c42b Mon Sep 17 00:00:00 2001 From: Tom Waite Date: Wed, 19 Aug 2026 17:04:30 -0700 Subject: [PATCH 05/10] docs: describe the RFC 9421 signing flow across docs and skills Co-Authored-By: Claude Fable 5 --- .changeset/rfc9421-signatures.md | 7 +++ cli/README.md | 12 ++--- cli/REGISTRATION.md | 8 ++-- skills/agentkit-x402/SKILL.md | 50 +++++++++++---------- skills/integrate-agentkit-x402/SKILL.md | 5 ++- skills/integrate-agentkit/SKILL.md | 20 ++++++--- x402/DOCS.md | 58 ++++++++++++++----------- 7 files changed, 94 insertions(+), 66 deletions(-) create mode 100644 .changeset/rfc9421-signatures.md diff --git a/.changeset/rfc9421-signatures.md b/.changeset/rfc9421-signatures.md new file mode 100644 index 0000000..137346d --- /dev/null +++ b/.changeset/rfc9421-signatures.md @@ -0,0 +1,7 @@ +--- +'@worldcoin/agentkit-core': minor +'@worldcoin/agentkit': minor +'@worldcoin/agentkit-cli': minor +--- + +Replace the bare EIP-191 body signature with RFC 9421 HTTP Message Signatures. Requests are now signed under a closed profile covering `@method`, `@authority`, `@path`, `@query`, and `content-digest` (RFC 9530), with `created`/`expires`/`nonce`/`keyid` parameters, transported in the standard `Signature-Input`, `Signature`, and `Content-Digest` headers instead of `X-AgentKit`. The CLI's `prove` command now takes ` [body]` and returns the three header values, `verify` enforces the five-minute validity window and keyid binding, and the x402 hooks enforce single-use nonces through restored `hasUsedNonce`/`recordNonce` storage methods. diff --git a/cli/README.md b/cli/README.md index 4624430..25c864d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -28,16 +28,18 @@ Full registration guide: [REGISTRATION.md](./REGISTRATION.md) ## Sign a request as this agent -Pass the exact UTF-8 request body to `prove`: +Pass the HTTP method, the full URL, and the exact UTF-8 request body to `prove`: ```bash -agentkit prove '{"query":"weather","city":"Lisbon"}' +agentkit prove POST 'https://api.example.com/data' '{"query":"weather","city":"Lisbon"}' ``` -For a request with no body, pass an empty string: +For a request with no body, omit the last argument: ```bash -agentkit prove '' +agentkit prove GET 'https://api.example.com/data' ``` -The command requires the key created by `agentkit register` and confirms that its address is registered before signing. It returns a `signature` field containing the hexadecimal value for the `X-AgentKit` request header. The retried request body must exactly match the body passed to `prove`. +The command requires the key created by `agentkit register` and confirms that its address is registered before signing. It returns a `headers` object with three values — `Content-Digest`, `Signature-Input`, and `Signature` (RFC 9421 HTTP message signatures) — to copy onto the request unmodified. + +The signature is bound to the method, host, path, query string, and body, expires after five minutes, and carries a single-use nonce. Send the request with the exact same method, URL, and byte-identical body, and run `prove` again for every new request — signed headers must never be reused. diff --git a/cli/REGISTRATION.md b/cli/REGISTRATION.md index 2ad9dc1..5c0eafe 100644 --- a/cli/REGISTRATION.md +++ b/cli/REGISTRATION.md @@ -54,12 +54,12 @@ Run `agentkit register` again and complete the World App step within five minute Check the network connection and retry. The command checks registration before starting a new verification, so it is safe to rerun after an uncertain response. -## Sign an x402 request body +## Sign an x402 request -After registration, pass the exact UTF-8 request body to `prove`: +After registration, pass the HTTP method, full URL, and exact UTF-8 request body to `prove`: ```bash -agentkit prove '' +agentkit prove POST 'https://api.example.com/data' '' ``` -Use `agentkit prove ''` for a request with no body. The command does not create a missing key and will not sign for an unregistered identity. On success, send its hexadecimal `signature` result in the `X-AgentKit` header and retry with the exact same body. +Omit the body argument for a request with no body. The command does not create a missing key and will not sign for an unregistered identity. On success, copy the returned `Content-Digest`, `Signature-Input`, and `Signature` header values onto the retry unmodified, and send it with the exact same method, URL, and body. Signed headers expire after five minutes and are single-use — run `prove` again for each request. diff --git a/skills/agentkit-x402/SKILL.md b/skills/agentkit-x402/SKILL.md index f1d2c06..36353de 100644 --- a/skills/agentkit-x402/SKILL.md +++ b/skills/agentkit-x402/SKILL.md @@ -1,6 +1,6 @@ --- name: agentkit-x402 -description: Use when an HTTP 402 Payment Required response contains an `agentkit` extension. Sign the request body with the AgentKit CLI, retry with the hexadecimal signature in `X-AgentKit`, interpret free, free-trial, and discount modes, and fall back to x402 payment only when AgentKit access is unavailable. +description: Use when an HTTP 402 Payment Required response contains an `agentkit` extension. Sign the request with the AgentKit CLI, retry with the returned `Signature-Input`, `Signature`, and `Content-Digest` headers, interpret free, free-trial, and discount modes, and fall back to x402 payment only when AgentKit access is unavailable. --- # Authenticate to x402 with AgentKit @@ -16,17 +16,17 @@ Fall back to the normal x402 payment flow only when: - the service rejects the signature; or - free-trial or discount access is exhausted. -The lowercase `agentkit` name is only the x402 extension key. Never send it as the authentication header. The request header is `X-AgentKit`. +The lowercase `agentkit` name is only the x402 extension key. Never send it as a header. Authentication uses the standard HTTP message signature headers `Signature-Input`, `Signature`, and `Content-Digest`. -Do not read or request a private key. Do not construct signatures manually. The AgentKit CLI loads the managed identity, confirms that it is registered, and signs the body. +Do not read or request a private key. Do not construct signatures manually. The AgentKit CLI loads the managed identity, confirms that it is registered, and signs the request. ## Authenticate the request ### 1. Prepare the body that will be retried -The signature covers the request body, so the body passed to `agentkit prove` and the body sent on the retry must be identical UTF-8 text. +The signature covers the request method, URL, and body, so the body passed to `agentkit prove` and the body sent on the retry must be identical UTF-8 text. -- For a request with no body, use the empty string. +- For a request with no body, omit the body argument. - For JSON, parse it and serialize it once as compact JSON. Use that exact compact JSON for both signing and the retry. Do not pretty-print or reorder it afterward. - The portable x402 hooks flow supports bodyless and JSON requests. Use plain text only when the service explicitly documents exact-body AgentKit support outside the standard x402 hooks. - Do not use the CLI flow for arbitrary text, binary, multipart, or form-encoded bodies when the service's body handling is unknown. Use an AgentKit-aware framework integration or continue with the normal x402 payment flow. @@ -37,43 +37,47 @@ Example compact JSON body: {"query":"weather","city":"Lisbon"} ``` -### 2. Ask the CLI to sign that exact body +### 2. Ask the CLI to sign the request -Pass the body as the single argument: +Pass the HTTP method, the full URL (including any query string), and the exact body: ```bash -agentkit prove '' +agentkit prove POST 'https://api.example.com/data' '' ``` For a bodyless request: ```bash -agentkit prove '' +agentkit prove GET 'https://api.example.com/data' ``` If the CLI is not installed globally: ```bash -npx @worldcoin/agentkit-cli prove '' +npx @worldcoin/agentkit-cli prove POST 'https://api.example.com/data' '' ``` The command: - loads the existing AgentKit identity without creating a key; - checks that its address is registered in AgentBook; and -- returns a `signature` field containing a hexadecimal EIP-191 signature. +- returns a `headers` object with three values: `Content-Digest`, `Signature-Input`, and `Signature`. -Use the returned `signature` directly. It is already the complete `X-AgentKit` header value. Do not encode, decode, wrap, or edit it. +Copy all three returned header values onto the retry unmodified. Do not encode, decode, wrap, or edit them. ### 3. Retry the original request -Repeat the request with the same method, URL, prepared body, and other headers, adding: +Repeat the request with the exact same method, URL, prepared body, and other headers, adding the three headers returned by `prove`: ```text -X-AgentKit: +Content-Digest: +Signature-Input: +Signature: ``` -The retried body must be byte-for-byte identical to the body given to `prove`. In particular, if JSON was compacted before signing, send that compact form on the retry. +The retried method, URL, and body must exactly match what was passed to `prove`. In particular, if JSON was compacted before signing, send that compact form on the retry. + +Signed headers expire after five minutes and are single-use. Run `prove` again for every request — never reuse headers across requests or retries after a change. If the service grants access, return the resource without paying. If it responds with another 402, interpret the access mode before deciding whether to pay. @@ -81,13 +85,13 @@ If the service grants access, return the resource without paying. If it responds Read `extensions.agentkit.mode` when present: -| Mode | Behavior | -| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `free` | Retry with `X-AgentKit` and no payment. | -| `free-trial` | Retry with `X-AgentKit` and no payment until the service reports that the per-human allowance is exhausted. Then use the normal payment flow. | -| `discount` | Keep `X-AgentKit` on the request and use the normal x402 payment flow with the discounted amount advertised by the service. | +| Mode | Behavior | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `free` | Retry with the signature headers and no payment. | +| `free-trial` | Retry with the signature headers and no payment until the service reports that the per-human allowance is exhausted. Then use the normal payment flow. | +| `discount` | Keep the signature headers on the request and use the normal x402 payment flow with the discounted amount advertised by the service. | -If no mode is present, try `X-AgentKit` without payment first. +If no mode is present, try the signature headers without payment first. ## Recover from errors @@ -99,7 +103,7 @@ No local AgentKit identity exists. Run: agentkit register ``` -Registration creates the local identity, checks AgentBook, and starts World ID verification only when needed. Human action may be required. After registration completes, retry `prove` with the same prepared request body. +Registration creates the local identity, checks AgentBook, and starts World ID verification only when needed. Human action may be required. After registration completes, run `prove` again for the same request. ### `AGENT_NOT_REGISTERED` @@ -119,4 +123,4 @@ Report the signing failure and retry once. Never ask the user to paste the priva ### Server rejects the signature -First verify that the retry used `X-AgentKit`, not `agentkit`, and that its body exactly matches the body passed to `prove`. Recreate the signature after any body change. If a second correctly signed retry is rejected and the service still requires payment, continue with the normal x402 payment flow. +Verify that all three headers were copied unmodified, that the retry used the exact method, URL, and body passed to `prove`, and that the headers are fresh — they expire after five minutes and each set is valid for one request only. Run `prove` again after any change or rejection. If a second freshly signed retry is rejected and the service still requires payment, continue with the normal x402 payment flow. diff --git a/skills/integrate-agentkit-x402/SKILL.md b/skills/integrate-agentkit-x402/SKILL.md index 4608261..bf47bc9 100644 --- a/skills/integrate-agentkit-x402/SKILL.md +++ b/skills/integrate-agentkit-x402/SKILL.md @@ -29,7 +29,7 @@ For most production integrations: - x402 resource server: the protected HTTP route and 402 retry flow - facilitator: verifies and settles payment payloads; required for `discount` -- AgentKit extension: advertises AgentKit support and verifies the request body's `X-AgentKit` signature +- AgentKit extension: advertises AgentKit support and verifies the request's RFC 9421 signature (`Signature-Input`, `Signature`, and `Content-Digest` headers) - AgentBook: on-chain registry on World Chain that maps the agent wallet to an anonymous human ID. Lookup is always against World Chain regardless of the payment chain — the caller side is chain-agnostic. - storage: per-human usage tracking for `free-trial` and `discount` - registration path: separate from request-time verification; use `npx @worldcoin/agentkit-cli --llms` if the developer also needs registration help @@ -50,7 +50,8 @@ For most production integrations: - 402 response includes the `agentkit` extension - registered agent gets the intended behavior - unregistered agent falls back to normal payment - - the client signs the normalized body and retries with `X-AgentKit` + - the client signs the request and retries with the `Signature-Input`, `Signature`, and `Content-Digest` headers + - a replayed signature (same nonce) is rejected when storage implements the nonce methods - usage storage behaves as expected ## Ground rules diff --git a/skills/integrate-agentkit/SKILL.md b/skills/integrate-agentkit/SKILL.md index e6e62a9..75c3391 100644 --- a/skills/integrate-agentkit/SKILL.md +++ b/skills/integrate-agentkit/SKILL.md @@ -1,6 +1,6 @@ --- name: integrate-agentkit -description: Protect one HTTP endpoint with @worldcoin/agentkit-core. Use this skill when an application must validate X-AgentKit body signatures, identify a registered human, or add AgentKit authentication to an API route. +description: Protect one HTTP endpoint with @worldcoin/agentkit-core. Use this skill when an application must validate AgentKit RFC 9421 request signatures, identify a registered human, or add AgentKit authentication to an API route. --- # Integrate AgentKit @@ -68,14 +68,18 @@ Use a stable machine code such as `AGENTKIT_VALIDATION_FAILED`. Do not return the internal verification error. The internal error can contain an address or a network detail. If the application has a server logger, record the internal error there. -## Preserve the signed body +## Preserve the signed request -Pass the original request to `verify`. Do not parse, format, or rebuild the body first. +Pass the original request to `verify`. Do not parse, format, or rebuild the body first, and do not rewrite the method, path, query string, or host — the signature binds all of them. -If the framework does not use Web `Request`, capture the exact body before a body parser changes it. Build one Web `Request` with those exact bytes and the original `X-AgentKit` header. +If the framework does not use Web `Request`, capture the exact body before a body parser changes it. Build one Web `Request` with the original method and full URL, those exact body bytes, and the original `Signature-Input`, `Signature`, and `Content-Digest` headers. + +Behind a proxy, the URL that `verify` sees must reflect the public host the client signed. Make sure the framework applies `X-Forwarded-Host` (or equivalent) first. Do not use a parsed JSON object as a replacement for the original body bytes. +For endpoints where a five-minute replay window is unacceptable, use `verifyRequest(request, { checkNonce })` instead of `verify` and back `checkNonce` with a store that records each nonce once (TTL of at least 300 seconds). + ## Keep the change local - Change only the selected endpoint and its direct tests. @@ -88,13 +92,15 @@ Do not use a parsed JSON object as a replacement for the original body bytes. Test these cases: -1. A request without `X-AgentKit` returns status `401` and the required message. +1. A request without the `Signature-Input`, `Signature`, and `Content-Digest` headers returns status `401` and the required message. 2. A malformed signature returns the same safe error. 3. An unregistered signer returns the same safe error. 4. A registered signer can use the endpoint. 5. A changed body invalidates the signature. -6. The endpoint can read the body after `verify` succeeds. -7. An endpoint outside the selected route stays unchanged. +6. A signature created for a different method, URL, or query string is rejected. +7. An expired signature (older than five minutes) is rejected. +8. The endpoint can read the body after `verify` succeeds. +9. An endpoint outside the selected route stays unchanged. Run the normal formatter, type checker, and relevant tests for the application. diff --git a/x402/DOCS.md b/x402/DOCS.md index 7b13de2..7b849cc 100644 --- a/x402/DOCS.md +++ b/x402/DOCS.md @@ -1,6 +1,6 @@ # AgentKit x402 Extension -Add proof-of-personhood access policies to x402 resources. A registered agent signs the request body, the server verifies the `X-AgentKit` signature through the canonical AgentBook on World Chain, and the access policy is applied per human. +Add proof-of-personhood access policies to x402 resources. A registered agent signs its request with an RFC 9421 HTTP message signature, the server verifies the signature through the canonical AgentBook on World Chain, and the access policy is applied per human. ## Install @@ -28,11 +28,11 @@ npx @worldcoin/agentkit-cli register 1. The client calls the protected resource normally. 2. The server returns `402 Payment Required` with `extensions.agentkit`. -3. The client normalizes and signs the request body, then retries with the hexadecimal signature in `X-AgentKit`. -4. The server calls Core's `verify(request)`, which recovers the signer and resolves its human nullifier from AgentBook on World Chain. -5. The hooks grant access, consume a trial use, or prepare a discounted payment according to the configured mode. +3. The client signs the request under the AgentKit RFC 9421 profile — binding the method, host, path, query string, a digest of the normalized body, a five-minute validity window, and a single-use nonce — then retries with the `Signature-Input`, `Signature`, and `Content-Digest` headers. +4. The server calls Core's `verify(request)`, which rebuilds the signature base from the request it actually received, recovers the signer, and resolves its human nullifier from AgentBook on World Chain. +5. The hooks record the nonce, then grant access, consume a trial use, or prepare a discounted payment according to the configured mode. -The `agentkit` string is the lowercase x402 extension key. The HTTP request header is always `X-AgentKit`. +The `agentkit` string is the lowercase x402 extension key. The request carries the standard RFC 9421 headers `Signature-Input` and `Signature` (labeled `agentkit`) plus `Content-Digest`. ## Client @@ -43,6 +43,7 @@ import { createAgentkitClient } from '@worldcoin/agentkit' const agentkit = createAgentkitClient({ signer: { + address: agentWallet.address, signMessage: message => agentWallet.signMessage({ message }), }, }) @@ -60,23 +61,23 @@ The built-in x402 adapters expose parsed JSON rather than raw request bytes. The ### Custom clients -`createHeader(body)` returns the hexadecimal value to place in `X-AgentKit`: +`createHeaders({ method, url, body })` returns the three signature headers to place on the request: ```typescript const body = { query: 'weather', city: 'Lisbon' } -const signature = await agentkit.createHeader(body) +const signatureHeaders = await agentkit.createHeaders({ method: 'POST', url, body }) const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-AgentKit': signature, + ...signatureHeaders, }, body: JSON.stringify(body), }) ``` -When using `createHeader` directly, the sent body must match `normalizeAgentkitBody(body)` exactly. +When using `createHeaders` directly, the request must use the exact method and URL that were signed, and the sent body must match `normalizeAgentkitBody(body)` exactly. Signed headers expire after five minutes and are single-use — create fresh headers for every request. ## Server hooks @@ -200,18 +201,18 @@ Register this with `x402ResourceServer.registerExtension(...)`. It adds the publ ### `createAgentkitClient(options)` -| Option | Type | Description | -| --------- | --------------------------------------------------- | ----------------------------------------- | -| `signer` | `{ signMessage(message: string): Promise }` | EIP-191 EOA signer. | -| `fetch` | `typeof fetch` | Optional underlying fetch implementation. | -| `onEvent` | `(event: AgentkitFetchEvent) => void` | Optional client event callback. | +| Option | Type | Description | +| --------- | ---------------------------------------------------------------------- | ------------------------------------------------ | +| `signer` | `{ address: string; signMessage(message: string): Promise }` | Agent address and EIP-191 signer. | +| `fetch` | `typeof fetch` | Optional underlying fetch implementation. | +| `onEvent` | `(event: AgentkitFetchEvent) => void` | Optional client event callback. | Returns: -| Field | Description | -| -------------------- | --------------------------------------------------------------------------- | -| `fetch` | Fetch-compatible function that retries AgentKit-enabled 402 responses once. | -| `createHeader(body)` | Signs `normalizeAgentkitBody(body)` and returns the `X-AgentKit` value. | +| Field | Description | +| ------------------------------------ | ------------------------------------------------------------------------------------------------ | +| `fetch` | Fetch-compatible function that retries AgentKit-enabled 402 responses once. | +| `createHeaders({ method, url, body })` | Signs the request under the AgentKit RFC 9421 profile and returns the three signature headers. | ### `createAgentkitHooks(options)` @@ -227,23 +228,28 @@ Returns `requestHook` and, only for discount mode, `verifyFailureHook`. ```typescript interface AgentKitStorage { - tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise + tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise + + hasUsedNonce?(nonce: string): Promise + recordNonce?(nonce: string): Promise } ``` -The check and increment must be atomic. +The check and increment must be atomic. When both nonce methods are implemented, the hooks reject any signature whose nonce was already recorded, making every signed request single-use. Production implementations should make the nonce check-and-record atomic (e.g. Redis `SET NX EX 300`) and expire entries with a TTL of at least the 300-second signature window. Without nonce storage, replays of a captured signature are only bounded by the five-minute validity window. ### Body helpers - `normalizeAgentkitBody(body)` converts a parsed body to the UTF-8 text used for signing. - `normalizeAgentkitRequestBody(request)` reads and normalizes a client's Fetch request body. - `AGENTKIT` is the x402 extension key, `agentkit`. -- `AGENTKIT_HEADER` is the request header name, `X-AgentKit`. +- `AGENTKIT_SIGNATURE_INPUT_HEADER`, `AGENTKIT_SIGNATURE_HEADER`, and `AGENTKIT_CONTENT_DIGEST_HEADER` are the request header names `Signature-Input`, `Signature`, and `Content-Digest`. ## Security considerations -- The signature authenticates only the normalized request body. It does not automatically bind the method, URL, host, audience, timestamp, or nonce. Put any required context in the signed body and validate it in the application. -- Core currently uses recoverable EIP-191 EOA signatures. Smart-contract and counterfactual-wallet signatures are not supported by this format. +- The signature binds the method, host, path, query string, a digest of the normalized body, a five-minute validity window, and a nonce. The server rebuilds every covered component from the request it actually received, so a signature cannot be replayed against a different service, endpoint, or payload. +- Provide storage with nonce methods to make signatures single-use. Without it, an identical request can be replayed for up to five minutes. +- Core uses recoverable EIP-191 EOA signatures over the RFC 9421 signature base, and the recovered signer must match the `keyid` address. Smart-contract and counterfactual-wallet signatures are not yet supported. +- The signature binds `@authority`, so the URL the server verifies against must reflect the public host. Behind a proxy, make sure the framework applies `X-Forwarded-Host` (or equivalent) before the hook reads the request URL. - AgentBook is queried on World Chain for every verification, so registration state is not selected by the x402 payment network. - JSON normalization is part of the x402 hooks contract. A custom client must sign and send the same normalized representation. - Trial and discount storage must be atomic and persistent in production. @@ -252,9 +258,11 @@ The check and increment must be atomic. ### Signature verification fails -- Confirm the header is `X-AgentKit`, not `agentkit`. -- Confirm the header value is the raw hexadecimal signature, not base64 or JSON. +- Confirm all three headers are present: `Signature-Input`, `Signature`, and `Content-Digest`. +- Confirm the headers were copied unmodified and the signature has not expired (five-minute window) or been used before. +- Confirm the retry uses the exact method and URL (including the query string) that were signed. - Confirm the retried body is the same normalized body that was signed. +- Behind a proxy, confirm the server sees the public host the client signed, not an internal one. - For the hooks path, use an empty or JSON request body. ### AgentBook lookup fails From 52265a962bac6cdcbac1acfd6316102c17d7a7c5 Mon Sep 17 00:00:00 2001 From: Tom Waite Date: Thu, 20 Aug 2026 17:06:20 -0700 Subject: [PATCH 06/10] feat!: drop the nonce from the signature profile Single-use signatures via a nonce move to a follow-up PR. Replay of a byte-identical request is bounded by the five-minute created/expires window until then; docs state this explicitly. Co-Authored-By: Claude Fable 5 --- .changeset/rfc9421-signatures.md | 2 +- cli/README.md | 2 +- cli/REGISTRATION.md | 2 +- cli/test/prove.test.ts | 1 - core/src/signature.ts | 32 ++++++----------- core/src/verify.ts | 14 -------- core/tests/signature.test.ts | 19 +++------- core/tests/verify.test.ts | 21 ----------- skills/agentkit-x402/SKILL.md | 4 +-- skills/integrate-agentkit-x402/SKILL.md | 1 - skills/integrate-agentkit/SKILL.md | 2 +- x402/DOCS.md | 17 ++++----- x402/src/hooks.ts | 10 +----- x402/src/storage.ts | 18 ---------- x402/tests/client-e2e.test.ts | 16 ++++----- x402/tests/client.test.ts | 2 +- x402/tests/hooks.test.ts | 47 ++----------------------- 17 files changed, 40 insertions(+), 170 deletions(-) diff --git a/.changeset/rfc9421-signatures.md b/.changeset/rfc9421-signatures.md index 137346d..91d2148 100644 --- a/.changeset/rfc9421-signatures.md +++ b/.changeset/rfc9421-signatures.md @@ -4,4 +4,4 @@ '@worldcoin/agentkit-cli': minor --- -Replace the bare EIP-191 body signature with RFC 9421 HTTP Message Signatures. Requests are now signed under a closed profile covering `@method`, `@authority`, `@path`, `@query`, and `content-digest` (RFC 9530), with `created`/`expires`/`nonce`/`keyid` parameters, transported in the standard `Signature-Input`, `Signature`, and `Content-Digest` headers instead of `X-AgentKit`. The CLI's `prove` command now takes ` [body]` and returns the three header values, `verify` enforces the five-minute validity window and keyid binding, and the x402 hooks enforce single-use nonces through restored `hasUsedNonce`/`recordNonce` storage methods. +Replace the bare EIP-191 body signature with RFC 9421 HTTP Message Signatures. Requests are now signed under a closed profile covering `@method`, `@authority`, `@path`, `@query`, and `content-digest` (RFC 9530), with `created`/`expires`/`keyid` parameters, transported in the standard `Signature-Input`, `Signature`, and `Content-Digest` headers instead of `X-AgentKit`. The CLI's `prove` command now takes ` [body]` and returns the three header values, and `verify` enforces the five-minute validity window and keyid binding. Nonce-based single-use signatures are a planned follow-up. diff --git a/cli/README.md b/cli/README.md index 25c864d..04e3fa2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -42,4 +42,4 @@ agentkit prove GET 'https://api.example.com/data' The command requires the key created by `agentkit register` and confirms that its address is registered before signing. It returns a `headers` object with three values — `Content-Digest`, `Signature-Input`, and `Signature` (RFC 9421 HTTP message signatures) — to copy onto the request unmodified. -The signature is bound to the method, host, path, query string, and body, expires after five minutes, and carries a single-use nonce. Send the request with the exact same method, URL, and byte-identical body, and run `prove` again for every new request — signed headers must never be reused. +The signature is bound to the method, host, path, query string, and body, and expires after five minutes. Send the request with the exact same method, URL, and byte-identical body, and run `prove` again for each new request. diff --git a/cli/REGISTRATION.md b/cli/REGISTRATION.md index 5c0eafe..7bb2403 100644 --- a/cli/REGISTRATION.md +++ b/cli/REGISTRATION.md @@ -62,4 +62,4 @@ After registration, pass the HTTP method, full URL, and exact UTF-8 request body agentkit prove POST 'https://api.example.com/data' '' ``` -Omit the body argument for a request with no body. The command does not create a missing key and will not sign for an unregistered identity. On success, copy the returned `Content-Digest`, `Signature-Input`, and `Signature` header values onto the retry unmodified, and send it with the exact same method, URL, and body. Signed headers expire after five minutes and are single-use — run `prove` again for each request. +Omit the body argument for a request with no body. The command does not create a missing key and will not sign for an unregistered identity. On success, copy the returned `Content-Digest`, `Signature-Input`, and `Signature` header values onto the retry unmodified, and send it with the exact same method, URL, and body. Signed headers expire after five minutes — run `prove` again for each request. diff --git a/cli/test/prove.test.ts b/cli/test/prove.test.ts index 4a90b47..7824ce4 100644 --- a/cli/test/prove.test.ts +++ b/cli/test/prove.test.ts @@ -33,7 +33,6 @@ describe('createProofHeaders', () => { expect(result.nullifierHash).toBe('0x1234') expect(result.address).toBe(signer.address.toLowerCase()) - expect(result.nonce.length).toBeGreaterThanOrEqual(16) }) it('signs bodyless GET requests with an empty-body digest', async () => { diff --git a/core/src/signature.ts b/core/src/signature.ts index 8bad641..6f47c82 100644 --- a/core/src/signature.ts +++ b/core/src/signature.ts @@ -5,10 +5,10 @@ import { bytesToHex, hexToBytes, sha256, type Hex } from 'viem' * * The profile is closed: exactly one signature labeled `agentkit`, covering exactly * `("@method" "@authority" "@path" "@query" "content-digest")`, with the parameters - * `created`, `expires`, `nonce`, `keyid` (lowercase agent address) and `tag="agentkit"` - * in that order. The signature is a 65-byte EIP-191 `personal_sign` over the UTF-8 - * signature base, transported as standard padded base64. Anything outside this exact - * shape is rejected. + * `created`, `expires`, `keyid` (lowercase agent address) and `tag="agentkit"` in that + * order. The signature is a 65-byte EIP-191 `personal_sign` over the UTF-8 signature + * base, transported as standard padded base64. Anything outside this exact shape is + * rejected. * * Example signature base for `POST https://api.example.com/data?x=1` with body `{"a":1}`: * @@ -17,7 +17,7 @@ import { bytesToHex, hexToBytes, sha256, type Hex } from 'viem' * "@path": /data * "@query": ?x=1 * "content-digest": sha-256=:AVq9f1zFei3ZS3WQ8ErYCEJzkF7jPsXOvq5iJ2qX+GI=: - * "@signature-params": ("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;nonce="mAyU1DSTCXHDXqzm5g1D3A==";keyid="0x0123456789abcdef0123456789abcdef01234567";tag="agentkit" + * "@signature-params": ("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;keyid="0x0123456789abcdef0123456789abcdef01234567";tag="agentkit" */ export const SIGNATURE_INPUT_HEADER = 'Signature-Input' @@ -30,11 +30,9 @@ export const CLOCK_SKEW_SECONDS = 5 const MAX_HEADER_LENGTH = 4096 const COVERED_COMPONENTS = '("@method" "@authority" "@path" "@query" "content-digest")' const KEYID_PATTERN = /^0x[0-9a-f]{40}$/ -// Printable ASCII excluding `"` and `\`, so the value never needs sf-string escaping. -const NONCE_PATTERN = /^[\x20-\x21\x23-\x5B\x5D-\x7E]{16,256}$/ const TIMESTAMP = '(0|[1-9][0-9]{0,14})' const SIGNATURE_PARAMS_PATTERN = new RegExp( - `^\\("@method" "@authority" "@path" "@query" "content-digest"\\);created=${TIMESTAMP};expires=${TIMESTAMP};nonce="([\\x20-\\x21\\x23-\\x5B\\x5D-\\x7E]{16,256})";keyid="(0x[0-9a-f]{40})";tag="agentkit"$` + `^\\("@method" "@authority" "@path" "@query" "content-digest"\\);created=${TIMESTAMP};expires=${TIMESTAMP};keyid="(0x[0-9a-f]{40})";tag="agentkit"$` ) // 65 signature bytes and 32 digest bytes always encode to these exact padded lengths. const SIGNATURE_VALUE_PATTERN = /^agentkit=:([A-Za-z0-9+/]{87}=):$/ @@ -43,7 +41,6 @@ const CONTENT_DIGEST_PATTERN = /^sha-256=:([A-Za-z0-9+/]{43}=):$/ export interface SignatureParams { created: number expires: number - nonce: string keyid: string } @@ -70,7 +67,6 @@ export interface CreateSignatureHeadersInput { /** Unix seconds; defaults to the current time. */ now?: number expiresInSeconds?: number - nonce?: string } export function deriveComponents(method: string, url: string | URL) { @@ -88,14 +84,13 @@ export function computeContentDigest(bodyBytes: Uint8Array): string { return `sha-256=:${encodeBase64(sha256(bodyBytes, 'bytes'))}:` } -export function serializeSignatureParams({ created, expires, nonce, keyid }: SignatureParams): string { +export function serializeSignatureParams({ created, expires, keyid }: SignatureParams): string { if (!Number.isInteger(created) || created < 0 || !Number.isInteger(expires) || expires <= created) { throw new Error('Signature params require integer timestamps with expires after created') } - if (!NONCE_PATTERN.test(nonce)) throw new Error('Signature nonce must be 16-256 printable ASCII characters') if (!KEYID_PATTERN.test(keyid)) throw new Error('Signature keyid must be a lowercase 0x address') - return `${COVERED_COMPONENTS};created=${created};expires=${expires};nonce="${nonce}";keyid="${keyid}";tag="${SIGNATURE_LABEL}"` + return `${COVERED_COMPONENTS};created=${created};expires=${expires};keyid="${keyid}";tag="${SIGNATURE_LABEL}"` } export function buildSignatureBase(input: { @@ -129,7 +124,7 @@ export function parseSignatureInput(raw: string): ParsedSignatureInput { const expires = Number(match[2]) if (expires <= created) throw new Error('Signature expires must be after created') - return { rawParams, created, expires, nonce: match[3]!, keyid: match[4]! } + return { rawParams, created, expires, keyid: match[3]! } } export function parseSignatureHeader(raw: string): Hex { @@ -163,10 +158,9 @@ export async function createSignatureHeaders(input: CreateSignatureHeadersInput) const contentDigest = computeContentDigest(bodyBytes) const created = input.now ?? Math.floor(Date.now() / 1000) const expires = created + (input.expiresInSeconds ?? MAX_SIGNATURE_AGE_SECONDS) - const nonce = input.nonce ?? generateNonce() const keyid = input.address.toLowerCase() - const signatureParams = serializeSignatureParams({ created, expires, nonce, keyid }) + const signatureParams = serializeSignatureParams({ created, expires, keyid }) const base = buildSignatureBase({ method: input.method, url: input.url, contentDigest, signatureParams }) const signature = await input.signMessage(base) @@ -179,12 +173,6 @@ export async function createSignatureHeaders(input: CreateSignatureHeadersInput) } } -function generateNonce(): string { - const bytes = new Uint8Array(16) - crypto.getRandomValues(bytes) - return encodeBase64(bytes) -} - const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' const BASE64_INDEX = new Map([...BASE64_ALPHABET].map((char, index) => [char, index])) diff --git a/core/src/verify.ts b/core/src/verify.ts index f97a706..beb5eb0 100644 --- a/core/src/verify.ts +++ b/core/src/verify.ts @@ -16,7 +16,6 @@ import { export type VerifiedAgentRequest = { nullifierHash: string address: string - nonce: string created: number expires: number } @@ -24,8 +23,6 @@ export type VerifiedAgentRequest = { type VerifyRequestDependencies = { recoverAddress?: (signatureBase: string, signature: Hex) => Promise lookupNullifierHash?: (address: string) => Promise - /** Return false when the nonce was already seen; used to enforce single-use signatures. */ - checkNonce?: (details: { nonce: string; address: string; created: number; expires: number }) => Promise /** Unix seconds; injectable for tests. */ now?: () => number } @@ -90,16 +87,6 @@ export async function verifyRequest( throw verificationError('Signature does not match the keyid address', 'KEYID_MISMATCH') } - if (dependencies.checkNonce) { - const fresh = await dependencies.checkNonce({ - nonce: params.nonce, - address: params.keyid, - created: params.created, - expires: params.expires, - }) - if (!fresh) throw verificationError('Signature nonce has already been used', 'NONCE_REUSED', params.keyid) - } - const lookup = dependencies.lookupNullifierHash ?? (signer => lookupNullifierHash(signer)) const nullifierHash = await lookup(params.keyid) if (!nullifierHash) { @@ -109,7 +96,6 @@ export async function verifyRequest( return { nullifierHash, address: params.keyid, - nonce: params.nonce, created: params.created, expires: params.expires, } diff --git a/core/tests/signature.test.ts b/core/tests/signature.test.ts index 1748608..2fcf201 100644 --- a/core/tests/signature.test.ts +++ b/core/tests/signature.test.ts @@ -17,12 +17,11 @@ const encoder = new TextEncoder() const EXAMPLE_PARAMS = { created: 1755600000, expires: 1755600300, - nonce: 'mAyU1DSTCXHDXqzm5g1D3A==', keyid: '0x0123456789abcdef0123456789abcdef01234567', } const EXAMPLE_RAW_PARAMS = - '("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;nonce="mAyU1DSTCXHDXqzm5g1D3A==";keyid="0x0123456789abcdef0123456789abcdef01234567";tag="agentkit"' + '("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;keyid="0x0123456789abcdef0123456789abcdef01234567";tag="agentkit"' describe('deriveComponents', () => { it('normalizes method, authority, path, and query identically on both sides', () => { @@ -86,9 +85,6 @@ describe('serializeSignatureParams', () => { it('rejects invalid inputs before they can be signed', () => { expect(() => serializeSignatureParams({ ...EXAMPLE_PARAMS, expires: EXAMPLE_PARAMS.created })).toThrow() expect(() => serializeSignatureParams({ ...EXAMPLE_PARAMS, created: 1.5 })).toThrow() - expect(() => serializeSignatureParams({ ...EXAMPLE_PARAMS, nonce: 'short' })).toThrow() - expect(() => serializeSignatureParams({ ...EXAMPLE_PARAMS, nonce: 'contains"quote-0123456789' })).toThrow() - expect(() => serializeSignatureParams({ ...EXAMPLE_PARAMS, nonce: 'contains\\slash-0123456789' })).toThrow() expect(() => serializeSignatureParams({ ...EXAMPLE_PARAMS, keyid: '0x0123456789ABCDEF0123456789abcdef01234567' }) ).toThrow() @@ -105,12 +101,11 @@ describe('createSignatureHeaders', () => { address: account.address, signMessage: message => account.signMessage({ message }), now: EXAMPLE_PARAMS.created, - nonce: EXAMPLE_PARAMS.nonce, }) expect(headers['Content-Digest']).toBe('sha-256=:AVq9f1zFei3ZS3WQ8ErYCEJzkF7jPsXOvq5iJ2qX+GI=:') expect(headers['Signature-Input']).toBe( - `agentkit=("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;nonce="mAyU1DSTCXHDXqzm5g1D3A==";keyid="${account.address.toLowerCase()}";tag="agentkit"` + `agentkit=("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;keyid="${account.address.toLowerCase()}";tag="agentkit"` ) const signature = parseSignatureHeader(headers.Signature) @@ -125,7 +120,7 @@ describe('createSignatureHeaders', () => { expect(await recoverMessageAddress({ message: base, signature })).toBe(account.address) }) - it('generates a fresh nonce and current window by default', async () => { + it('uses the current five-minute window by default', async () => { const account = privateKeyToAccount(`0x${'02'.padStart(64, '0')}`) const before = Math.floor(Date.now() / 1000) const headers = await createSignatureHeaders({ @@ -138,7 +133,6 @@ describe('createSignatureHeaders', () => { const parsed = parseSignatureInput(headers['Signature-Input']) expect(parsed.created).toBeGreaterThanOrEqual(before) expect(parsed.expires).toBe(parsed.created + 300) - expect(parsed.nonce.length).toBeGreaterThanOrEqual(16) }) }) @@ -151,10 +145,9 @@ describe('parseSignatureInput', () => { it.each([ ['wrong label', valid.replace('agentkit=', 'evil=')], - ['duplicate members', `${valid}, evil=("@method");created=1;expires=2;nonce="0123456789abcdef";keyid="0x0123456789abcdef0123456789abcdef01234567";tag="agentkit"`], + ['duplicate members', `${valid}, evil=("@method");created=1;expires=2;keyid="0x0123456789abcdef0123456789abcdef01234567";tag="agentkit"`], ['missing created', valid.replace(';created=1755600000', '')], ['missing expires', valid.replace(';expires=1755600300', '')], - ['missing nonce', valid.replace(';nonce="mAyU1DSTCXHDXqzm5g1D3A=="', '')], ['missing keyid', valid.replace(';keyid="0x0123456789abcdef0123456789abcdef01234567"', '')], ['missing tag', valid.replace(';tag="agentkit"', '')], ['extra alg param', valid.replace(';tag="agentkit"', ';tag="agentkit";alg="ed25519"')], @@ -167,11 +160,9 @@ describe('parseSignatureInput', () => { ['oversized timestamp', valid.replace('created=1755600000', `created=${'9'.repeat(16)}`)], ['expires before created', valid.replace('expires=1755600300', 'expires=1755599999')], ['uppercase keyid', valid.replace('0x0123456789abcdef', '0x0123456789ABCDEF')], - ['short nonce', valid.replace('mAyU1DSTCXHDXqzm5g1D3A==', 'short')], - ['nonce with backslash', valid.replace('mAyU1DSTCXHDXqzm5g1D3A==', 'mAyU1DSTCXHDXqzm5g1D3\\=')], ['wrong tag', valid.replace('tag="agentkit"', 'tag="other"')], ['trailing garbage', `${valid};x=1`], - ['oversized header', `agentkit=${EXAMPLE_RAW_PARAMS.replace('mAyU1DSTCXHDXqzm5g1D3A==', 'a'.repeat(5000))}`], + ['oversized header', `agentkit=${'a'.repeat(5000)}`], ])('rejects %s', (_name, value) => { expect(() => parseSignatureInput(value)).toThrow() }) diff --git a/core/tests/verify.test.ts b/core/tests/verify.test.ts index 1120076..b17d033 100644 --- a/core/tests/verify.test.ts +++ b/core/tests/verify.test.ts @@ -69,7 +69,6 @@ describe('verifyRequest', () => { expect(result.address).toBe(account.address.toLowerCase()) expect(result.created).toBe(NOW) expect(result.expires).toBe(NOW + 300) - expect(result.nonce.length).toBeGreaterThanOrEqual(16) expect(lookups).toEqual([account.address.toLowerCase()]) expect(await request.text()).toBe('{"a":1}') }) @@ -163,26 +162,6 @@ describe('verifyRequest', () => { ) }) - it('rejects a reused nonce via the checkNonce dependency', async () => { - const { account, request } = await signedRequest() - const seen: Array<{ nonce: string; address: string; created: number; expires: number }> = [] - - await expect( - verifyRequest(request, { - ...registered(account), - checkNonce: async details => { - seen.push(details) - return false - }, - }) - ).rejects.toThrow('Signature nonce has already been used') - - expect(seen).toHaveLength(1) - expect(seen[0]!.address).toBe(account.address.toLowerCase()) - expect(seen[0]!.created).toBe(NOW) - expect(seen[0]!.expires).toBe(NOW + 300) - }) - it('throws when the recovered signer is not registered', async () => { const { request } = await signedRequest() await expect( diff --git a/skills/agentkit-x402/SKILL.md b/skills/agentkit-x402/SKILL.md index 36353de..b630e4c 100644 --- a/skills/agentkit-x402/SKILL.md +++ b/skills/agentkit-x402/SKILL.md @@ -77,7 +77,7 @@ Signature: The retried method, URL, and body must exactly match what was passed to `prove`. In particular, if JSON was compacted before signing, send that compact form on the retry. -Signed headers expire after five minutes and are single-use. Run `prove` again for every request — never reuse headers across requests or retries after a change. +Signed headers expire after five minutes. Run `prove` again for every request — never reuse headers across different requests or after any change to the request. If the service grants access, return the resource without paying. If it responds with another 402, interpret the access mode before deciding whether to pay. @@ -123,4 +123,4 @@ Report the signing failure and retry once. Never ask the user to paste the priva ### Server rejects the signature -Verify that all three headers were copied unmodified, that the retry used the exact method, URL, and body passed to `prove`, and that the headers are fresh — they expire after five minutes and each set is valid for one request only. Run `prove` again after any change or rejection. If a second freshly signed retry is rejected and the service still requires payment, continue with the normal x402 payment flow. +Verify that all three headers were copied unmodified, that the retry used the exact method, URL, and body passed to `prove`, and that the headers are fresh — they expire after five minutes. Run `prove` again after any change or rejection. If a second freshly signed retry is rejected and the service still requires payment, continue with the normal x402 payment flow. diff --git a/skills/integrate-agentkit-x402/SKILL.md b/skills/integrate-agentkit-x402/SKILL.md index bf47bc9..de919fd 100644 --- a/skills/integrate-agentkit-x402/SKILL.md +++ b/skills/integrate-agentkit-x402/SKILL.md @@ -51,7 +51,6 @@ For most production integrations: - registered agent gets the intended behavior - unregistered agent falls back to normal payment - the client signs the request and retries with the `Signature-Input`, `Signature`, and `Content-Digest` headers - - a replayed signature (same nonce) is rejected when storage implements the nonce methods - usage storage behaves as expected ## Ground rules diff --git a/skills/integrate-agentkit/SKILL.md b/skills/integrate-agentkit/SKILL.md index 75c3391..ac27268 100644 --- a/skills/integrate-agentkit/SKILL.md +++ b/skills/integrate-agentkit/SKILL.md @@ -78,7 +78,7 @@ Behind a proxy, the URL that `verify` sees must reflect the public host the clie Do not use a parsed JSON object as a replacement for the original body bytes. -For endpoints where a five-minute replay window is unacceptable, use `verifyRequest(request, { checkNonce })` instead of `verify` and back `checkNonce` with a store that records each nonce once (TTL of at least 300 seconds). +A byte-identical request can be replayed until its signature expires (at most five minutes). Nonce-based single-use signatures are a planned follow-up. Until then, if duplicate execution would be harmful for the endpoint, keep it idempotent or deduplicate at the application level (e.g. on a request ID inside the signed body). ## Keep the change local diff --git a/x402/DOCS.md b/x402/DOCS.md index 7b849cc..27903da 100644 --- a/x402/DOCS.md +++ b/x402/DOCS.md @@ -28,9 +28,9 @@ npx @worldcoin/agentkit-cli register 1. The client calls the protected resource normally. 2. The server returns `402 Payment Required` with `extensions.agentkit`. -3. The client signs the request under the AgentKit RFC 9421 profile — binding the method, host, path, query string, a digest of the normalized body, a five-minute validity window, and a single-use nonce — then retries with the `Signature-Input`, `Signature`, and `Content-Digest` headers. +3. The client signs the request under the AgentKit RFC 9421 profile — binding the method, host, path, query string, a digest of the normalized body, and a five-minute validity window — then retries with the `Signature-Input`, `Signature`, and `Content-Digest` headers. 4. The server calls Core's `verify(request)`, which rebuilds the signature base from the request it actually received, recovers the signer, and resolves its human nullifier from AgentBook on World Chain. -5. The hooks record the nonce, then grant access, consume a trial use, or prepare a discounted payment according to the configured mode. +5. The hooks grant access, consume a trial use, or prepare a discounted payment according to the configured mode. The `agentkit` string is the lowercase x402 extension key. The request carries the standard RFC 9421 headers `Signature-Input` and `Signature` (labeled `agentkit`) plus `Content-Digest`. @@ -77,7 +77,7 @@ const response = await fetch(url, { }) ``` -When using `createHeaders` directly, the request must use the exact method and URL that were signed, and the sent body must match `normalizeAgentkitBody(body)` exactly. Signed headers expire after five minutes and are single-use — create fresh headers for every request. +When using `createHeaders` directly, the request must use the exact method and URL that were signed, and the sent body must match `normalizeAgentkitBody(body)` exactly. Signed headers expire after five minutes — create fresh headers for every request. ## Server hooks @@ -229,13 +229,10 @@ Returns `requestHook` and, only for discount mode, `verifyFailureHook`. ```typescript interface AgentKitStorage { tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise - - hasUsedNonce?(nonce: string): Promise - recordNonce?(nonce: string): Promise } ``` -The check and increment must be atomic. When both nonce methods are implemented, the hooks reject any signature whose nonce was already recorded, making every signed request single-use. Production implementations should make the nonce check-and-record atomic (e.g. Redis `SET NX EX 300`) and expire entries with a TTL of at least the 300-second signature window. Without nonce storage, replays of a captured signature are only bounded by the five-minute validity window. +The check and increment must be atomic. ### Body helpers @@ -246,8 +243,8 @@ The check and increment must be atomic. When both nonce methods are implemented, ## Security considerations -- The signature binds the method, host, path, query string, a digest of the normalized body, a five-minute validity window, and a nonce. The server rebuilds every covered component from the request it actually received, so a signature cannot be replayed against a different service, endpoint, or payload. -- Provide storage with nonce methods to make signatures single-use. Without it, an identical request can be replayed for up to five minutes. +- The signature binds the method, host, path, query string, a digest of the normalized body, and a five-minute validity window. The server rebuilds every covered component from the request it actually received, so a signature cannot be replayed against a different service, endpoint, or payload. +- A byte-identical request can be replayed until its signature expires (at most five minutes). Nonce-based single-use signatures are a planned follow-up; until then, keep protected operations idempotent where duplicate execution would be harmful. - Core uses recoverable EIP-191 EOA signatures over the RFC 9421 signature base, and the recovered signer must match the `keyid` address. Smart-contract and counterfactual-wallet signatures are not yet supported. - The signature binds `@authority`, so the URL the server verifies against must reflect the public host. Behind a proxy, make sure the framework applies `X-Forwarded-Host` (or equivalent) before the hook reads the request URL. - AgentBook is queried on World Chain for every verification, so registration state is not selected by the x402 payment network. @@ -259,7 +256,7 @@ The check and increment must be atomic. When both nonce methods are implemented, ### Signature verification fails - Confirm all three headers are present: `Signature-Input`, `Signature`, and `Content-Digest`. -- Confirm the headers were copied unmodified and the signature has not expired (five-minute window) or been used before. +- Confirm the headers were copied unmodified and the signature has not expired (five-minute window). - Confirm the retry uses the exact method and URL (including the query string) that were signed. - Confirm the retried body is the same normalized body that was signed. - Behind a proxy, confirm the server sees the public host the client signed, not an internal one. diff --git a/x402/src/hooks.ts b/x402/src/hooks.ts index 0b3f11c..88c90e6 100644 --- a/x402/src/hooks.ts +++ b/x402/src/hooks.ts @@ -93,15 +93,7 @@ export function createAgentkitHooksInternal( // GET/HEAD requests cannot carry a body; core digests the empty byte string. ...(method === 'GET' || method === 'HEAD' ? {} : { body }), }) - const { nullifierHash: humanId, address, nonce } = await verify(verificationRequest) - - if (storage?.hasUsedNonce && storage?.recordNonce) { - if (await storage.hasUsedNonce(nonce)) { - onEvent?.({ type: 'validation_failed', resource: context.path, error: 'Signature nonce already used' }) - return - } - await storage.recordNonce(nonce) - } + const { nullifierHash: humanId, address } = await verify(verificationRequest) if (mode.type === 'free') { onEvent?.({ type: 'agent_verified', resource: context.path, address, humanId }) diff --git a/x402/src/storage.ts b/x402/src/storage.ts index 82e6061..779cf1b 100644 --- a/x402/src/storage.ts +++ b/x402/src/storage.ts @@ -7,20 +7,10 @@ export interface AgentKitStorage { * (e.g. a database transaction with row-level locking) to prevent TOCTOU race conditions. */ tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise - - /** - * Optional replay protection: when both nonce methods are implemented, the hooks reject - * any signature whose nonce was already recorded. Production implementations should make - * check-and-record atomic (e.g. Redis SET NX EX) and expire entries with a TTL of at - * least the 300-second signature window. - */ - hasUsedNonce?(nonce: string): Promise - recordNonce?(nonce: string): Promise } export class InMemoryAgentKitStorage implements AgentKitStorage { private usage = new Map() - private nonces = new Set() async tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise { const key = `${endpoint}:${humanId}` @@ -29,12 +19,4 @@ export class InMemoryAgentKitStorage implements AgentKitStorage { this.usage.set(key, count + 1) return true } - - async hasUsedNonce(nonce: string): Promise { - return this.nonces.has(nonce) - } - - async recordNonce(nonce: string): Promise { - this.nonces.add(nonce) - } } diff --git a/x402/tests/client-e2e.test.ts b/x402/tests/client-e2e.test.ts index 55d6fd6..e015d75 100644 --- a/x402/tests/client-e2e.test.ts +++ b/x402/tests/client-e2e.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'bun:test' import { createSignatureHeaders } from '../../core/src/signature' import { verifyRequest } from '../../core/src/verify' import { createAgentkitHooksInternal } from '../src/hooks' -import { InMemoryAgentKitStorage } from '../src/storage' import { createAgentkitClient, type AgentKitStorage } from '../src' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { @@ -133,11 +132,11 @@ describe('AgentKit client/server E2E', () => { ]) }) - it('rejects an identical replay and a tampered body under the same signature', async () => { + it('rejects a tampered body under the same signature', async () => { const account = privateKeyToAccount(generatePrivateKey()) const events: Array> = [] const hooks = createAgentkitHooksInternal( - { storage: new InMemoryAgentKitStorage(), onEvent: event => events.push(event as Record) }, + { onEvent: event => events.push(event as Record) }, { verify: request => verifyRequest(request, { lookupNullifierHash: async () => 'human-1' }) } ) @@ -166,15 +165,16 @@ describe('AgentKit client/server E2E', () => { await expect(hooks.requestHook({ adapter: adapter({ hello: 'world' }), path: '/protected' })).resolves.toEqual({ grantAccess: true, }) - await expect( - hooks.requestHook({ adapter: adapter({ hello: 'world' }), path: '/protected' }) - ).resolves.toBeUndefined() + // Until nonce-based single-use lands, a byte-identical replay inside the + // five-minute window verifies again by design. + await expect(hooks.requestHook({ adapter: adapter({ hello: 'world' }), path: '/protected' })).resolves.toEqual({ + grantAccess: true, + }) await expect( hooks.requestHook({ adapter: adapter({ hello: 'tampered' }), path: '/protected' }) ).resolves.toBeUndefined() - expect(events.map(event => event.type)).toEqual(['agent_verified', 'validation_failed', 'validation_failed']) - expect(events[1]!.error).toBe('Signature nonce already used') + expect(events.map(event => event.type)).toEqual(['agent_verified', 'agent_verified', 'validation_failed']) expect(events[2]!.error).toBe('Content-Digest does not match the request body') }) diff --git a/x402/tests/client.test.ts b/x402/tests/client.test.ts index 13b385d..9fde52a 100644 --- a/x402/tests/client.test.ts +++ b/x402/tests/client.test.ts @@ -56,7 +56,7 @@ describe('createAgentkitClient', () => { expect(headers['Content-Digest']).toMatch(/^sha-256=:[A-Za-z0-9+/]{43}=:$/) expect(headers['Signature-Input']).toMatch( new RegExp( - `^agentkit=\\("@method" "@authority" "@path" "@query" "content-digest"\\);created=\\d+;expires=\\d+;nonce="[^"]+";keyid="${ADDRESS.toLowerCase()}";tag="agentkit"$` + `^agentkit=\\("@method" "@authority" "@path" "@query" "content-digest"\\);created=\\d+;expires=\\d+;keyid="${ADDRESS.toLowerCase()}";tag="agentkit"$` ) ) expect(headers.Signature).toMatch(/^agentkit=:[A-Za-z0-9+/]{87}=:$/) diff --git a/x402/tests/hooks.test.ts b/x402/tests/hooks.test.ts index a6c7130..73ae2c7 100644 --- a/x402/tests/hooks.test.ts +++ b/x402/tests/hooks.test.ts @@ -1,19 +1,18 @@ import { describe, expect, it } from 'bun:test' import type { VerifiedAgentRequest } from '@worldcoin/agentkit-core' -import { AgentKitStorage, InMemoryAgentKitStorage } from '../src/storage' +import { AgentKitStorage } from '../src/storage' import { createAgentkitHooksInternal } from '../src/hooks' const ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' const URL_ = 'https://agentkit.example/protected' const SIGNATURE_INPUT = - 'agentkit=("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;nonce="mAyU1DSTCXHDXqzm5g1D3A==";keyid="0x1234567890abcdef1234567890abcdef12345678";tag="agentkit"' + 'agentkit=("@method" "@authority" "@path" "@query" "content-digest");created=1755600000;expires=1755600300;keyid="0x1234567890abcdef1234567890abcdef12345678";tag="agentkit"' const SIGNATURE = `agentkit=:${'A'.repeat(87)}=:` const CONTENT_DIGEST = 'sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:' const VERIFIED: VerifiedAgentRequest = { nullifierHash: 'human-1', address: ADDRESS, - nonce: 'mAyU1DSTCXHDXqzm5g1D3A==', created: 1755600000, expires: 1755600300, } @@ -136,48 +135,6 @@ describe('createAgentkitHooks', () => { ]) }) - it('rejects a replayed nonce when storage tracks nonces', async () => { - const events: Array> = [] - const hooks = createAgentkitHooksInternal( - { storage: new InMemoryAgentKitStorage(), onEvent: event => events.push(event as Record) }, - dependencies - ) - - await expect(hooks.requestHook({ adapter: createAdapter(), path: '/protected' })).resolves.toEqual({ - grantAccess: true, - }) - await expect(hooks.requestHook({ adapter: createAdapter(), path: '/protected' })).resolves.toBeUndefined() - - expect(events.filter(event => event.type === 'validation_failed')).toEqual([ - { type: 'validation_failed', resource: '/protected', error: 'Signature nonce already used' }, - ]) - }) - - it('records the verified nonce exactly once on success', async () => { - const recorded: string[] = [] - const storage: AgentKitStorage = { - tryIncrementUsage: async () => true, - hasUsedNonce: async () => false, - recordNonce: async nonce => { - recorded.push(nonce) - }, - } - const hooks = createAgentkitHooksInternal({ storage }, dependencies) - - await hooks.requestHook({ adapter: createAdapter(), path: '/protected' }) - - expect(recorded).toEqual([VERIFIED.nonce]) - }) - - it('still grants access when storage does not implement nonce tracking', async () => { - const storage: AgentKitStorage = { tryIncrementUsage: async () => true } - const hooks = createAgentkitHooksInternal({ mode: { type: 'free-trial', uses: 3 }, storage }, dependencies) - - await expect(hooks.requestHook({ adapter: createAdapter(), path: '/protected' })).resolves.toEqual({ - grantAccess: true, - }) - }) - it('uses the nullifier hash to recover discounted underpayments', async () => { const usageCalls: Array<{ endpoint: string; humanId: string; limit: number }> = [] const events: Array> = [] From 335dc164e09eefd857409335063ca91ff7e0a5a7 Mon Sep 17 00:00:00 2001 From: Tom Waite Date: Fri, 21 Aug 2026 15:37:43 -0700 Subject: [PATCH 07/10] fix(x402): match pending discounts case-insensitively and checksum surfaced addresses verifyRequest now returned the lowercase wire keyid while extractPayer reads the payment payload's from verbatim (usually EIP-55 checksummed), so the pendingDiscounts lookup never matched and discount recovery silently stopped firing. Discount keys now lowercase both sides, and core surfaces EIP-55 checksummed addresses (results, error addresses, lookups), restoring the pre-RFC-9421 observable behavior. Co-Authored-By: Claude Fable 5 --- cli/test/prove.test.ts | 4 ++-- core/src/verify.ts | 11 +++++++---- core/tests/verify.test.ts | 10 +++++----- x402/DOCS.md | 1 + x402/src/hooks.ts | 6 ++++-- x402/tests/client-e2e.test.ts | 6 +++--- x402/tests/hooks.test.ts | 25 +++++++++++++++++++++++++ 7 files changed, 47 insertions(+), 16 deletions(-) diff --git a/cli/test/prove.test.ts b/cli/test/prove.test.ts index 7824ce4..8a2f318 100644 --- a/cli/test/prove.test.ts +++ b/cli/test/prove.test.ts @@ -14,7 +14,7 @@ function createSigner(privateKey: `0x${string}`): AgentSigner & { account: Retur } function registeredLookup(signer: { address: string }) { - return async (address: string) => (address === signer.address.toLowerCase() ? '0x1234' : null) + return async (address: string) => (address === signer.address ? '0x1234' : null) } describe('createProofHeaders', () => { @@ -32,7 +32,7 @@ describe('createProofHeaders', () => { const result = await verifyRequest(request, { lookupNullifierHash: registeredLookup(signer) }) expect(result.nullifierHash).toBe('0x1234') - expect(result.address).toBe(signer.address.toLowerCase()) + expect(result.address).toBe(signer.address) }) it('signs bodyless GET requests with an empty-body digest', async () => { diff --git a/core/src/verify.ts b/core/src/verify.ts index beb5eb0..3aba028 100644 --- a/core/src/verify.ts +++ b/core/src/verify.ts @@ -1,4 +1,4 @@ -import { isAddressEqual, recoverMessageAddress, type Hex } from 'viem' +import { getAddress, isAddressEqual, recoverMessageAddress, type Hex } from 'viem' import { lookupNullifierHash } from './agent-book' import { CLOCK_SKEW_SECONDS, @@ -87,15 +87,18 @@ export async function verifyRequest( throw verificationError('Signature does not match the keyid address', 'KEYID_MISMATCH') } + // The wire format carries a lowercase keyid; surface the EIP-55 checksummed form. + const address = getAddress(params.keyid) + const lookup = dependencies.lookupNullifierHash ?? (signer => lookupNullifierHash(signer)) - const nullifierHash = await lookup(params.keyid) + const nullifierHash = await lookup(address) if (!nullifierHash) { - throw verificationError('Agent is not registered in AgentBook', 'AGENT_NOT_REGISTERED', params.keyid) + throw verificationError('Agent is not registered in AgentBook', 'AGENT_NOT_REGISTERED', address) } return { nullifierHash, - address: params.keyid, + address, created: params.created, expires: params.expires, } diff --git a/core/tests/verify.test.ts b/core/tests/verify.test.ts index b17d033..752f4cd 100644 --- a/core/tests/verify.test.ts +++ b/core/tests/verify.test.ts @@ -47,8 +47,7 @@ async function signedRequest(options: SignedRequestOptions = {}) { function registered(account: { address: string }) { return { now: () => NOW + 1, - lookupNullifierHash: async (address: string) => - address === account.address.toLowerCase() ? '0x1234' : null, + lookupNullifierHash: async (address: string) => (address === account.address ? '0x1234' : null), } } @@ -61,15 +60,16 @@ describe('verifyRequest', () => { now: () => NOW + 1, lookupNullifierHash: async address => { lookups.push(address) - return address === account.address.toLowerCase() ? '0x1234' : null + return address === account.address ? '0x1234' : null }, }) expect(result.nullifierHash).toBe('0x1234') - expect(result.address).toBe(account.address.toLowerCase()) + // viem accounts expose EIP-55 checksummed addresses; verifyRequest surfaces the same form. + expect(result.address).toBe(account.address) expect(result.created).toBe(NOW) expect(result.expires).toBe(NOW + 300) - expect(lookups).toEqual([account.address.toLowerCase()]) + expect(lookups).toEqual([account.address]) expect(await request.text()).toBe('{"a":1}') }) diff --git a/x402/DOCS.md b/x402/DOCS.md index 27903da..66ef12a 100644 --- a/x402/DOCS.md +++ b/x402/DOCS.md @@ -246,6 +246,7 @@ The check and increment must be atomic. - The signature binds the method, host, path, query string, a digest of the normalized body, and a five-minute validity window. The server rebuilds every covered component from the request it actually received, so a signature cannot be replayed against a different service, endpoint, or payload. - A byte-identical request can be replayed until its signature expires (at most five minutes). Nonce-based single-use signatures are a planned follow-up; until then, keep protected operations idempotent where duplicate execution would be harmful. - Core uses recoverable EIP-191 EOA signatures over the RFC 9421 signature base, and the recovered signer must match the `keyid` address. Smart-contract and counterfactual-wallet signatures are not yet supported. +- Addresses surfaced by the SDK (`verifyRequest` results, hook events, the recovered discount payer) are EIP-55 checksummed; the wire-format `keyid` is lowercase. Always compare addresses case-insensitively. - The signature binds `@authority`, so the URL the server verifies against must reflect the public host. Behind a proxy, make sure the framework applies `X-Forwarded-Host` (or equivalent) before the hook reads the request URL. - AgentBook is queried on World Chain for every verification, so registration state is not selected by the x402 payment network. - JSON normalization is part of the x402 hooks contract. A custom client must sign and send the same normalized representation. diff --git a/x402/src/hooks.ts b/x402/src/hooks.ts index 88c90e6..727eb28 100644 --- a/x402/src/hooks.ts +++ b/x402/src/hooks.ts @@ -121,7 +121,7 @@ export function createAgentkitHooksInternal( for (const [key, entry] of pendingDiscounts) { if (now - entry.createdAt > PENDING_TTL_MS) pendingDiscounts.delete(key) } - pendingDiscounts.set(`${context.path}:${address}`, { + pendingDiscounts.set(`${context.path}:${address.toLowerCase()}`, { humanId, address, createdAt: now, @@ -152,7 +152,9 @@ export function createAgentkitHooksInternal( }): Promise => { const resourcePath = new URL(context.paymentPayload.resource.url).pathname const payer = extractPayer(context.paymentPayload.payload) - const discountKey = payer ? `${resourcePath}:${payer}` : null + // Addresses compare case-insensitively: the stored key uses the lowercase + // signer address, payment payloads usually carry EIP-55 checksummed ones. + const discountKey = payer ? `${resourcePath}:${payer.toLowerCase()}` : null const pending = discountKey ? pendingDiscounts.get(discountKey) : undefined if (discountKey) pendingDiscounts.delete(discountKey) diff --git a/x402/tests/client-e2e.test.ts b/x402/tests/client-e2e.test.ts index e015d75..6e6a351 100644 --- a/x402/tests/client-e2e.test.ts +++ b/x402/tests/client-e2e.test.ts @@ -81,7 +81,7 @@ describe('AgentKit client/server E2E', () => { verifyRequest(request, { async lookupNullifierHash(address) { lookups.push(address) - return address === account.address.toLowerCase() ? 'human-1' : null + return address === account.address ? 'human-1' : null }, }), } @@ -115,7 +115,7 @@ describe('AgentKit client/server E2E', () => { expect(response.status).toBe(200) expect(body).toEqual({ ok: true }) expect(requestCount).toBe(2) - expect(lookups).toEqual([account.address.toLowerCase()]) + expect(lookups).toEqual([account.address]) expect(usageCalls).toEqual([{ endpoint: '/protected', humanId: 'human-1', limit: 3 }]) expect(clientEvents.map(event => event.type)).toEqual([ 'agentkit_detected', @@ -126,7 +126,7 @@ describe('AgentKit client/server E2E', () => { { type: 'agent_verified', resource: '/protected', - address: account.address.toLowerCase(), + address: account.address, humanId: 'human-1', }, ]) diff --git a/x402/tests/hooks.test.ts b/x402/tests/hooks.test.ts index 73ae2c7..601ef0e 100644 --- a/x402/tests/hooks.test.ts +++ b/x402/tests/hooks.test.ts @@ -1,3 +1,4 @@ +import { getAddress } from 'viem' import { describe, expect, it } from 'bun:test' import type { VerifiedAgentRequest } from '@worldcoin/agentkit-core' import { AgentKitStorage } from '../src/storage' @@ -179,6 +180,30 @@ describe('createAgentkitHooks', () => { ]) }) + it('matches pending discounts case-insensitively across address representations', async () => { + // Core surfaces EIP-55 checksummed addresses while payment payloads may carry any casing. + const checksummed = getAddress(ADDRESS) + const hooks = createAgentkitHooksInternal( + { + mode: { type: 'discount', percent: 50, uses: 2 }, + storage: { tryIncrementUsage: async () => true }, + }, + { verify: async () => ({ ...VERIFIED, address: checksummed }) } + ) + + await hooks.requestHook({ adapter: createAdapter(), path: '/protected' }) + const verifyResult = await hooks.verifyFailureHook?.({ + paymentPayload: { + resource: { url: URL_ }, + payload: { authorization: { from: ADDRESS.toUpperCase().replace('0X', '0x'), value: '50' } }, + }, + requirements: { amount: '100' }, + error: new Error('invalid_exact_evm_payload_authorization_value: discounted payment'), + }) + + expect(verifyResult).toEqual({ recovered: true, result: { isValid: true, payer: checksummed } }) + }) + it('reports an unregistered signer separately from a malformed signature', async () => { const events: Array> = [] const hooks = createAgentkitHooksInternal( From 8d763eef3fb63c773b17673eab0340198240e547 Mon Sep 17 00:00:00 2001 From: Tom Waite Date: Fri, 21 Aug 2026 15:41:04 -0700 Subject: [PATCH 08/10] fix(core): reject non-token HTTP methods in the signature base deriveComponents interpolated the caller's method string raw, so a direct createSignatureHeaders caller could inject extra lines into the base being signed. Verification was never spoofable (the verifier rebuilds a fixed six-line base from a real Request) and existing callers were guarded upstream, but core is the public signing API, so the letters-only check now lives at the shared choke point. Co-Authored-By: Claude Fable 5 --- core/src/signature.ts | 5 +++++ core/tests/signature.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/core/src/signature.ts b/core/src/signature.ts index 6f47c82..0bd8b71 100644 --- a/core/src/signature.ts +++ b/core/src/signature.ts @@ -30,6 +30,9 @@ export const CLOCK_SKEW_SECONDS = 5 const MAX_HEADER_LENGTH = 4096 const COVERED_COMPONENTS = '("@method" "@authority" "@path" "@query" "content-digest")' const KEYID_PATTERN = /^0x[0-9a-f]{40}$/ +// Closed profile: HTTP methods are letters only. This also keeps caller-supplied +// method strings from injecting lines into the signature base. +const METHOD_PATTERN = /^[A-Za-z]+$/ const TIMESTAMP = '(0|[1-9][0-9]{0,14})' const SIGNATURE_PARAMS_PATTERN = new RegExp( `^\\("@method" "@authority" "@path" "@query" "content-digest"\\);created=${TIMESTAMP};expires=${TIMESTAMP};keyid="(0x[0-9a-f]{40})";tag="agentkit"$` @@ -70,6 +73,8 @@ export interface CreateSignatureHeadersInput { } export function deriveComponents(method: string, url: string | URL) { + if (!METHOD_PATTERN.test(method)) throw new Error('Invalid HTTP method') + const parsed = typeof url === 'string' ? new URL(url) : url return { method: method.toUpperCase(), diff --git a/core/tests/signature.test.ts b/core/tests/signature.test.ts index 2fcf201..ffeb255 100644 --- a/core/tests/signature.test.ts +++ b/core/tests/signature.test.ts @@ -45,6 +45,14 @@ describe('deriveComponents', () => { it('uses the root path for a bare origin', () => { expect(deriveComponents('GET', 'https://api.example.com').path).toBe('/') }) + + it('rejects methods that could inject lines into the signature base', () => { + expect(() => deriveComponents('POST\n"@authority": evil.com', 'https://api.example.com')).toThrow( + 'Invalid HTTP method' + ) + expect(() => deriveComponents('GE T', 'https://api.example.com')).toThrow('Invalid HTTP method') + expect(() => deriveComponents('', 'https://api.example.com')).toThrow('Invalid HTTP method') + }) }) describe('computeContentDigest', () => { From 4da9659a646f932b788e66f5aec53eac45b9cb7c Mon Sep 17 00:00:00 2001 From: Tom Waite Date: Fri, 21 Aug 2026 15:43:18 -0700 Subject: [PATCH 09/10] fix(x402): skip body normalization for bodyless requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Body normalization ran before the method check, so a bodyless GET carrying Content-Type: application/json (a common HTTP-client default) passed undefined into normalizeAgentkitJsonBody and was rejected before verification. Determine the method first, skip body retrieval for GET/HEAD, and treat a missing adapter body as the signed empty body — matching what clients sign. Co-Authored-By: Claude Fable 5 --- x402/src/hooks.ts | 25 +++++++++++++++-------- x402/tests/hooks.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/x402/src/hooks.ts b/x402/src/hooks.ts index 727eb28..3de0965 100644 --- a/x402/src/hooks.ts +++ b/x402/src/hooks.ts @@ -71,16 +71,25 @@ export function createAgentkitHooksInternal( if (!signatureInput) return try { - const parsedBody = await context.adapter.getBody?.() - const contentType = context.adapter.getHeader('content-type')?.split(';', 1)[0]?.trim().toLowerCase() - const body = - contentType === 'application/json' || contentType?.endsWith('+json') - ? normalizeAgentkitJsonBody(parsedBody) - : normalizeAgentkitBody(parsedBody) + // Clients sign the empty body for bodyless requests regardless of content type, + // so determine the method first and skip body normalization for GET/HEAD. + const method = context.adapter.getMethod().toUpperCase() + const isBodyless = method === 'GET' || method === 'HEAD' + + let body = '' + if (!isBodyless) { + const parsedBody = await context.adapter.getBody?.() + const contentType = context.adapter.getHeader('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (parsedBody !== undefined) { + body = + contentType === 'application/json' || contentType?.endsWith('+json') + ? normalizeAgentkitJsonBody(parsedBody) + : normalizeAgentkitBody(parsedBody) + } + } // Rebuild the request core verifies against from what actually arrived: the real // method and URL, the signature headers, and the re-normalized body bytes. - const method = context.adapter.getMethod().toUpperCase() const headers = new Headers({ [AGENTKIT_SIGNATURE_INPUT_HEADER]: signatureInput }) const signatureHeader = context.adapter.getHeader(AGENTKIT_SIGNATURE_HEADER) if (signatureHeader) headers.set(AGENTKIT_SIGNATURE_HEADER, signatureHeader) @@ -91,7 +100,7 @@ export function createAgentkitHooksInternal( method, headers, // GET/HEAD requests cannot carry a body; core digests the empty byte string. - ...(method === 'GET' || method === 'HEAD' ? {} : { body }), + ...(isBodyless ? {} : { body }), }) const { nullifierHash: humanId, address } = await verify(verificationRequest) diff --git a/x402/tests/hooks.test.ts b/x402/tests/hooks.test.ts index 601ef0e..65ba176 100644 --- a/x402/tests/hooks.test.ts +++ b/x402/tests/hooks.test.ts @@ -96,6 +96,50 @@ describe('createAgentkitHooks', () => { expect(requests[0]!.body).toBeNull() }) + it('verifies a bodyless GET that carries a JSON content type', async () => { + const requests: Request[] = [] + const hooks = createAgentkitHooksInternal( + {}, + { + verify: async request => { + requests.push(request) + return VERIFIED + }, + } + ) + + await expect( + hooks.requestHook({ + adapter: createAdapter({ method: 'GET', body: undefined, contentType: 'application/json' }), + path: '/protected', + }) + ).resolves.toEqual({ grantAccess: true }) + + expect(requests[0]!.body).toBeNull() + }) + + it('treats a missing body as the signed empty body regardless of content type', async () => { + const requests: Request[] = [] + const hooks = createAgentkitHooksInternal( + {}, + { + verify: async request => { + requests.push(request) + return VERIFIED + }, + } + ) + + await expect( + hooks.requestHook({ + adapter: createAdapter({ method: 'DELETE', body: undefined, contentType: 'application/json' }), + path: '/protected', + }) + ).resolves.toEqual({ grantAccess: true }) + + expect(await requests[0]!.text()).toBe('') + }) + it('ignores requests without a Signature-Input header', async () => { const hooks = createAgentkitHooksInternal({}, dependencies) const adapter = { ...createAdapter(), getHeader: () => undefined } From d8b5eb29b555c5542d1ec5faa9ca89724b51b667 Mon Sep 17 00:00:00 2001 From: Tom Waite Date: Fri, 21 Aug 2026 15:50:33 -0700 Subject: [PATCH 10/10] fix(core): tolerate 30 seconds of client clock skew Five seconds only covered well-synced clocks; laptops after sleep, VMs, and containers without NTP are routinely seconds fast and would fail every request with SIGNATURE_NOT_YET_VALID. Slow clocks were already tolerated for up to 300 seconds via the age check, so this only evens out the asymmetry and extends the worst-case replay window marginally. Co-Authored-By: Claude Fable 5 --- core/src/signature.ts | 3 ++- core/tests/verify.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/signature.ts b/core/src/signature.ts index 0bd8b71..07c3022 100644 --- a/core/src/signature.ts +++ b/core/src/signature.ts @@ -25,7 +25,8 @@ export const SIGNATURE_HEADER = 'Signature' export const CONTENT_DIGEST_HEADER = 'Content-Digest' export const SIGNATURE_LABEL = 'agentkit' export const MAX_SIGNATURE_AGE_SECONDS = 300 -export const CLOCK_SKEW_SECONDS = 5 +// Tolerates created timestamps up to 30s in the future from out-of-sync client clocks. +export const CLOCK_SKEW_SECONDS = 30 const MAX_HEADER_LENGTH = 4096 const COVERED_COMPONENTS = '("@method" "@authority" "@path" "@query" "content-digest")' diff --git a/core/tests/verify.test.ts b/core/tests/verify.test.ts index 752f4cd..57b8a94 100644 --- a/core/tests/verify.test.ts +++ b/core/tests/verify.test.ts @@ -149,7 +149,7 @@ describe('verifyRequest', () => { 'Signature created timestamp is in the future' ) - const skewed = await signedRequest({ now: NOW + 3 }) + const skewed = await signedRequest({ now: NOW + 30 }) const result = await verifyRequest(skewed.request, { ...registered(skewed.account), now: () => NOW }) expect(result.nullifierHash).toBe('0x1234') })