Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/rfc9421-signatures.md
Original file line number Diff line number Diff line change
@@ -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 `<method> <url> [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.
28 changes: 7 additions & 21 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 4 additions & 4 deletions cli/REGISTRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<exact-request-body>'
agentkit prove POST 'https://api.example.com/data' '<exact-request-body>'
```

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.
3 changes: 2 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 21 additions & 6 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
})
}
},
Expand Down
34 changes: 28 additions & 6 deletions cli/src/prove.ts
Original file line number Diff line number Diff line change
@@ -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<AgentkitSignatureHeaders> {
return createSignatureHeaders({
method: input.method,
url: input.url,
body: input.body,
address: input.signer.address,
signMessage: message => input.signer.signMessage(message),
})
}
76 changes: 60 additions & 16 deletions cli/test/prove.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof privateKeyToAccount> } {
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'
)
})
})
5 changes: 4 additions & 1 deletion core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading