diff --git a/.changeset/rfc9421-signatures.md b/.changeset/rfc9421-signatures.md new file mode 100644 index 0000000..91d2148 --- /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`/`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/bun.lock b/bun.lock index c4e0235..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", @@ -46,7 +47,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 +58,9 @@ }, }, }, + "overrides": { + "ajv": "^8.18.0", + }, "packages": { "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], @@ -314,7 +318,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 +448,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 +546,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 +664,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 +776,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 +816,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 +834,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/README.md b/cli/README.md index 4624430..04e3fa2 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, 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 2ad9dc1..7bb2403 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 — run `prove` again for each request. diff --git a/cli/package.json b/cli/package.json index ef829b8..457b268 100644 --- a/cli/package.json +++ b/cli/package.json @@ -22,9 +22,10 @@ "scripts": { "build": "tsc", "cli": "tsx src/index.ts", - "test": "bun test" + "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..8a2f318 100644 --- a/cli/test/prove.test.ts +++ b/cli/test/prove.test.ts @@ -1,26 +1,70 @@ -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 ? '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) + }) + + 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' + ) }) }) 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..07c3022 --- /dev/null +++ b/core/src/signature.ts @@ -0,0 +1,224 @@ +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`, `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;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 +// 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")' +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"$` +) +// 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 + 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 +} + +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(), + 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, 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 (!KEYID_PATTERN.test(keyid)) throw new Error('Signature keyid must be a lowercase 0x address') + + return `${COVERED_COMPONENTS};created=${created};expires=${expires};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, keyid: match[3]! } +} + +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 keyid = input.address.toLowerCase() + + const signatureParams = serializeSignatureParams({ created, expires, 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))}:`, + } +} + +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..3aba028 100644 --- a/core/src/verify.ts +++ b/core/src/verify.ts @@ -1,24 +1,55 @@ -import { isHex, recoverMessageAddress, type Hex } from 'viem' +import { getAddress, 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 + created: number + expires: number +} type VerifyRequestDependencies = { - recoverAddress?: (body: Uint8Array, signature: Hex) => Promise + recoverAddress?: (signatureBase: string, signature: Hex) => Promise lookupNullifierHash?: (address: string) => 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 +59,63 @@ 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') } + // 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(address) if (!nullifierHash) { throw verificationError('Agent is not registered in AgentBook', 'AGENT_NOT_REGISTERED', address) } - return nullifierHash + return { + nullifierHash, + address, + 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..ffeb255 --- /dev/null +++ b/core/tests/signature.test.ts @@ -0,0 +1,217 @@ +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, + keyid: '0x0123456789abcdef0123456789abcdef01234567', +} + +const EXAMPLE_RAW_PARAMS = + '("@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', () => { + 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('/') + }) + + 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', () => { + 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, 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, + }) + + 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;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('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({ + 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) + }) +}) + +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;keyid="0x0123456789abcdef0123456789abcdef01234567";tag="agentkit"`], + ['missing created', valid.replace(';created=1755600000', '')], + ['missing expires', valid.replace(';expires=1755600300', '')], + ['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')], + ['wrong tag', valid.replace('tag="agentkit"', 'tag="other"')], + ['trailing garbage', `${valid};x=1`], + ['oversized header', `agentkit=${'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..57b8a94 100644 --- a/core/tests/verify.test.ts +++ b/core/tests/verify.test.ts @@ -1,81 +1,179 @@ 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 ? '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 ? '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') + // 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]) + 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, { - lookupNullifierHash: async address => - isAddressEqual(address as `0x${string}`, account.address) ? '0x1234' : null, - }) - ).rejects.toThrow('Agent is not registered in AgentBook') + await expect(verifyRequest(request, registered(account))).rejects.toThrow(/signature|keyid/i) }) - 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' + 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 + 30 }) + 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('throws when the recovered signer is not registered', async () => { + 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') }, 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/skills/agentkit-x402/SKILL.md b/skills/agentkit-x402/SKILL.md index f1d2c06..b630e4c 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. 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. @@ -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. 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..de919fd 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,7 @@ 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 - usage storage behaves as expected ## Ground rules diff --git a/skills/integrate-agentkit/SKILL.md b/skills/integrate-agentkit/SKILL.md index e6e62a9..ac27268 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. +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 - 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..66ef12a 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. +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 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 — 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,7 +228,7 @@ 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 } ``` @@ -238,12 +239,15 @@ The check and increment must be atomic. - `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, 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. - Trial and discount storage must be atomic and persistent in production. @@ -252,9 +256,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). +- 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 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", 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..3de0965 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,27 +59,50 @@ 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?.() - const contentType = context.adapter.getHeader('content-type')?.split(';', 1)[0]?.trim().toLowerCase() - const body = - contentType === 'application/json' || contentType?.endsWith('+json') - ? normalizeAgentkitJsonBody(parsedBody) - : normalizeAgentkitBody(parsedBody) - const bodyBytes = new TextEncoder().encode(body) + // 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 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. + ...(isBodyless ? {} : { body }), }) - const humanId = await verifyRequest(verificationRequest) - const address = await recoverAddress(bodyBytes, header as Hex) + const { nullifierHash: humanId, address } = await verify(verificationRequest) if (mode.type === 'free') { onEvent?.({ type: 'agent_verified', resource: context.path, address, humanId }) @@ -106,7 +130,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, @@ -137,7 +161,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/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/tests/client-e2e.test.ts b/x402/tests/client-e2e.test.ts index 4ba4a8e..6e6a351 100644 --- a/x402/tests/client-e2e.test.ts +++ b/x402/tests/client-e2e.test.ts @@ -1,9 +1,15 @@ 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 { 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 +43,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 +56,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 +81,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 ? 'human-1' : null }, }), } @@ -91,7 +100,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), }) @@ -123,8 +132,56 @@ describe('AgentKit client/server E2E', () => { ]) }) - it('does not treat the lowercase extension key as the request header name', () => { + it('rejects a tampered body under the same signature', async () => { + const account = privateKeyToAccount(generatePrivateKey()) + const events: Array> = [] + const hooks = createAgentkitHooksInternal( + { 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, + }) + // 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', 'agent_verified', 'validation_failed']) + 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..9fde52a 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+;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..65ba176 100644 --- a/x402/tests/hooks.test.ts +++ b/x402/tests/hooks.test.ts @@ -1,57 +1,150 @@ -import type { Hex } from 'viem' +import { getAddress } 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 } 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;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, + 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( + {}, + { + verify: async request => { + requests.push(request) + return VERIFIED + }, + } + ) + + await expect( + hooks.requestHook({ adapter: createAdapter({ contentType: 'application/json' }), path: '/protected' }) + ).resolves.toEqual({ grantAccess: true }) + + expect(requests).toHaveLength(1) + 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('verifies a bodyless GET that carries a JSON content type', 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'), + adapter: createAdapter({ method: 'GET', body: undefined, contentType: 'application/json' }), path: '/protected', }) ).resolves.toEqual({ grantAccess: true }) - expect(requests).toHaveLength(1) - expect(requests[0]!.headers.get(AGENTKIT_HEADER)).toBe(SIGNATURE) - expect(await requests[0]!.text()).toBe('{"hello":"world"}') + + 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 } + + await expect(hooks.requestHook({ adapter, path: '/protected' })).resolves.toBeUndefined() }) it('uses the nullifier hash to grant free-trial access', async () => { @@ -110,7 +203,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, @@ -131,12 +224,35 @@ 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( { 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',