diff --git a/.changeset/atomic-nonce-consumption.md b/.changeset/atomic-nonce-consumption.md new file mode 100644 index 0000000..be783fe --- /dev/null +++ b/.changeset/atomic-nonce-consumption.md @@ -0,0 +1,5 @@ +--- +'@worldcoin/agentkit': patch +--- + +Add atomic nonce consumption with expiry for concurrency-safe replay protection while preserving the legacy storage methods for compatibility. diff --git a/skills/integrate-agentkit/SKILL.md b/skills/integrate-agentkit/SKILL.md index b76e842..1e18da4 100644 --- a/skills/integrate-agentkit/SKILL.md +++ b/skills/integrate-agentkit/SKILL.md @@ -45,7 +45,7 @@ For most production integrations: - `createAgentBookVerifier` 4. If the payment network is World Chain (`eip155:480`), add a custom `ExactEvmScheme().registerMoneyParser(...)` for World Chain USDC. Do not assume the server scheme has a working default stablecoin for World Chain. 5. Call `createAgentBookVerifier()` with no arguments in the common case. Pass `rpcUrl` or `contractAddress` only for custom World Chain endpoints or non-canonical deployments. -6. If the mode is `free-trial` or `discount`, add persistent `AgentKitStorage`. `InMemoryAgentKitStorage` is only for demos. +6. If the mode is `free-trial` or `discount`, add persistent `AgentKitStorage`. `InMemoryAgentKitStorage` is only for demos. Implement `consumeNonce` as an atomic check-and-insert with expiry; separate nonce reads and writes are not concurrency-safe. 7. If the mode is `discount`, wire `hooks.verifyFailureHook` into the facilitator. Without it, discounted underpayments will fail verification. 8. Verify the whole path end-to-end: - 402 response includes the `agentkit` extension diff --git a/x402/DOCS.md b/x402/DOCS.md index 85c2c45..896dbbc 100644 --- a/x402/DOCS.md +++ b/x402/DOCS.md @@ -436,10 +436,29 @@ Storage interface for tracking per-human usage counts. | Method | Description | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `tryIncrementUsage(endpoint, humanId, limit)` | Atomically increment usage if below `limit`. Returns `true` if incremented, `false` if limit already reached. | -| `hasUsedNonce?(nonce)` | Optional: check for replay attacks. | -| `recordNonce?(nonce)` | Optional: record a used nonce. | +| `consumeNonce?(nonce, expiresAt)` | Optional: atomically record an unseen nonce. Return `false` for a replay. | +| `hasUsedNonce?(nonce)` | Deprecated compatibility check. Implement `consumeNonce` for atomic replay protection. | +| `recordNonce?(nonce)` | Deprecated compatibility recorder. Implement `consumeNonce` for atomic replay protection. | -`InMemoryAgentKitStorage` is the reference in-memory implementation. For production, implement `AgentKitStorage` with a persistent backend (e.g. using a database transaction with row-level locking). +`InMemoryAgentKitStorage` is the reference in-memory implementation. For production, implement `AgentKitStorage` with a persistent backend. `consumeNonce` must perform its check and insert atomically (for example with a unique database constraint or an atomic cache `SET NX`) and should expire the record at `expiresAt`. + +For example, a PostgreSQL implementation can use a unique nonce column and `INSERT ... ON CONFLICT DO NOTHING` in one statement: + +```typescript +async consumeNonce(nonce: string, expiresAt: Date): Promise { + const result = await db.query( + `INSERT INTO agentkit_nonces (nonce, expires_at) + SELECT $1, $2 + WHERE $2::timestamptz > clock_timestamp() + ON CONFLICT (nonce) DO NOTHING + RETURNING nonce`, + [nonce, expiresAt] + ) + return result.rowCount === 1 +} +``` + +The expiry check uses the database clock in the same atomic statement, so a delayed request returns `false` even if cleanup has already removed the nonce. Expired rows can be removed asynchronously; they must not be removed before their stored `expires_at` value. ### `parseAgentkitHeader(header)` diff --git a/x402/src/hooks.ts b/x402/src/hooks.ts index 57d98f5..e477386 100644 --- a/x402/src/hooks.ts +++ b/x402/src/hooks.ts @@ -83,8 +83,26 @@ export function createAgentkitHooks(options: CreateAgentkitHooksOptions) { return } - if (storage?.recordNonce) { - await storage.recordNonce(payload.nonce) + const nonceExpiration = getNonceExpiration(payload) + if (nonceExpiration.getTime() <= Date.now()) { + onEvent?.({ type: 'validation_failed', resource: context.path, error: 'Message expired' }) + return + } + + if (storage?.consumeNonce) { + const consumed = await storage.consumeNonce(payload.nonce, nonceExpiration) + if (!consumed) { + onEvent?.({ + type: 'validation_failed', + resource: context.path, + error: 'Nonce validation failed (possible replay attack)', + }) + return + } + } else { + // Backwards-compatible path for existing storage implementations. + // This cannot guarantee atomic replay protection; implement consumeNonce instead. + await storage?.recordNonce?.(payload.nonce) } const humanId = await agentBook.lookupHuman(verification.address) @@ -182,6 +200,14 @@ export function createAgentkitHooks(options: CreateAgentkitHooksOptions) { return { requestHook, verifyFailureHook } } +const DEFAULT_NONCE_MAX_AGE_MS = 5 * 60 * 1000 + +function getNonceExpiration(payload: { issuedAt: string; expirationTime?: string }): Date { + const maxAgeExpiration = new Date(payload.issuedAt).getTime() + DEFAULT_NONCE_MAX_AGE_MS + const explicitExpiration = payload.expirationTime ? new Date(payload.expirationTime).getTime() : Infinity + return new Date(Math.min(maxAgeExpiration, explicitExpiration)) +} + function extractPayer(payload: Record): string | null { try { if ('authorization' in payload) { diff --git a/x402/src/storage.ts b/x402/src/storage.ts index 2cd83a9..f4d8989 100644 --- a/x402/src/storage.ts +++ b/x402/src/storage.ts @@ -8,13 +8,26 @@ export interface AgentKitStorage { */ tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise + /** + * Atomically record a nonce only when it has not already been consumed. + * Returns `true` when the nonce was recorded and `false` when it is a replay. + * + * Implementations MUST perform the check and insert as one atomic operation. + * They MUST also reject records whose validity window has already ended. + * `expiresAt` is the end of the challenge validity window and can be used as + * the row or cache TTL. + */ + consumeNonce?(nonce: string, expiresAt: Date): Promise + + /** @deprecated Implement `consumeNonce` for atomic replay protection. */ hasUsedNonce?(nonce: string): Promise + /** @deprecated Implement `consumeNonce` for atomic replay protection. */ recordNonce?(nonce: string): Promise } export class InMemoryAgentKitStorage implements AgentKitStorage { private usage = new Map() - private nonces = new Set() + private nonces = new Map() async tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise { const key = `${endpoint}:${humanId}` @@ -24,11 +37,27 @@ export class InMemoryAgentKitStorage implements AgentKitStorage { return true } + async consumeNonce(nonce: string, expiresAt: Date): Promise { + const now = Date.now() + this.pruneExpiredNonces(now) + if (expiresAt.getTime() <= now) return false + if (this.nonces.has(nonce)) return false + this.nonces.set(nonce, expiresAt.getTime()) + return true + } + async hasUsedNonce(nonce: string): Promise { + this.pruneExpiredNonces(Date.now()) return this.nonces.has(nonce) } async recordNonce(nonce: string): Promise { - this.nonces.add(nonce) + this.nonces.set(nonce, Date.now() + 5 * 60 * 1000) + } + + private pruneExpiredNonces(now: number): void { + for (const [nonce, expiresAt] of this.nonces) { + if (expiresAt <= now) this.nonces.delete(nonce) + } } } diff --git a/x402/tests/docs.test.ts b/x402/tests/docs.test.ts new file mode 100644 index 0000000..e46cad7 --- /dev/null +++ b/x402/tests/docs.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'bun:test' + +const docs = readFileSync(new URL('../DOCS.md', import.meta.url), 'utf8') + +describe('AgentKitStorage documentation', () => { + it('rejects expired timestamps inside the PostgreSQL nonce insert', () => { + const example = docs.match( + /INSERT INTO agentkit_nonces[\s\S]*?ON CONFLICT \(nonce\) DO NOTHING[\s\S]*?RETURNING nonce/ + )?.[0] + + expect(example).toContain('WHERE $2::timestamptz > clock_timestamp()') + }) +}) diff --git a/x402/tests/hooks.test.ts b/x402/tests/hooks.test.ts index 38092a7..1c560d1 100644 --- a/x402/tests/hooks.test.ts +++ b/x402/tests/hooks.test.ts @@ -33,6 +33,30 @@ async function createSignedRequest(url = 'https://agentkit.example/protected') { } } +function createSmartWalletRequest( + url = 'https://agentkit.example/protected', + overrides: Partial = {} +) { + const payload: AgentkitPayload = { + domain: new URL(url).hostname, + address: '0x1111111111111111111111111111111111111111', + uri: url, + version: '1', + chainId: CHAIN_ID, + type: 'eip1271', + nonce: 'nonce1234', + issuedAt: new Date().toISOString(), + signature: '0x1234', + ...overrides, + } + + return { + header: Buffer.from(JSON.stringify(payload)).toString('base64'), + path: new URL(url).pathname, + url, + } +} + function createAdapter(url: string, header: string) { return { getHeader(name: string) { @@ -45,6 +69,214 @@ function createAdapter(url: string, header: string) { } describe('createAgentkitHooks', () => { + it('atomically consumes a nonce so concurrent replays grant access once', async () => { + const request = await createSignedRequest() + const consumedNonces = new Set() + let usageCount = 0 + const events: Array> = [] + const storage: AgentKitStorage = { + async tryIncrementUsage() { + usageCount += 1 + return true + }, + async consumeNonce(nonce) { + if (consumedNonces.has(nonce)) return false + consumedNonces.add(nonce) + return true + }, + } + + const hooks = createAgentkitHooks({ + agentBook: { lookupHuman: async () => 'human-1' }, + mode: { type: 'free-trial', uses: 3 }, + storage, + onEvent: event => events.push(event as Record), + }) + + const context = { + adapter: createAdapter(request.url, request.header), + path: request.path, + } + const results = await Promise.all([hooks.requestHook(context), hooks.requestHook(context)]) + + expect(results.filter(result => result?.grantAccess)).toHaveLength(1) + expect(usageCount).toBe(1) + expect(events.filter(event => event.type === 'validation_failed')).toHaveLength(1) + }) + + it('rejects known replays before smart-wallet RPC signature verification', async () => { + const request = createSmartWalletRequest() + let rpcCalls = 0 + const rpcServer = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(rpcRequest) { + rpcCalls += 1 + const body = (await rpcRequest.json()) as { id: number } + return Response.json({ jsonrpc: '2.0', id: body.id, result: '0x01' }) + }, + }) + let consumeCalls = 0 + let lookupCalls = 0 + const events: Array> = [] + const storage: AgentKitStorage = { + async tryIncrementUsage() { + return true + }, + async hasUsedNonce() { + return true + }, + async consumeNonce() { + consumeCalls += 1 + return true + }, + } + + try { + const hooks = createAgentkitHooks({ + agentBook: { + async lookupHuman() { + lookupCalls += 1 + return 'human-1' + }, + }, + storage, + rpcUrl: rpcServer.url.toString(), + onEvent: event => events.push(event as Record), + }) + + const result = await hooks.requestHook({ + adapter: createAdapter(request.url, request.header), + path: request.path, + }) + + expect(result).toBeUndefined() + expect(rpcCalls).toBe(0) + expect(consumeCalls).toBe(0) + expect(lookupCalls).toBe(0) + expect(events).toEqual([ + { + type: 'validation_failed', + resource: request.path, + error: 'Nonce validation failed (possible replay attack)', + }, + ]) + } finally { + rpcServer.stop(true) + } + }) + + it('rejects a nonce that expires during smart-wallet signature verification', async () => { + const originalDateNow = Date.now + const issuedAt = originalDateNow() + const maxAgeMs = 5 * 60 * 1000 + let currentTime = issuedAt + maxAgeMs - 1 + const request = createSmartWalletRequest('https://agentkit.example/protected', { + issuedAt: new Date(issuedAt).toISOString(), + }) + let rpcCalls = 0 + const rpcServer = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(rpcRequest) { + rpcCalls += 1 + currentTime = issuedAt + maxAgeMs + const body = (await rpcRequest.json()) as { id: number } + return Response.json({ jsonrpc: '2.0', id: body.id, result: '0x01' }) + }, + }) + let consumeCalls = 0 + let lookupCalls = 0 + const events: Array> = [] + const storage: AgentKitStorage = { + async tryIncrementUsage() { + return true + }, + async consumeNonce() { + consumeCalls += 1 + return true + }, + } + + Date.now = () => currentTime + try { + const hooks = createAgentkitHooks({ + agentBook: { + async lookupHuman() { + lookupCalls += 1 + return 'human-1' + }, + }, + storage, + rpcUrl: rpcServer.url.toString(), + onEvent: event => events.push(event as Record), + }) + + const result = await hooks.requestHook({ + adapter: createAdapter(request.url, request.header), + path: request.path, + }) + + expect(result).toBeUndefined() + expect(rpcCalls).toBe(1) + expect(consumeCalls).toBe(0) + expect(lookupCalls).toBe(0) + expect(events).toEqual([ + { + type: 'validation_failed', + resource: request.path, + error: 'Message expired', + }, + ]) + } finally { + Date.now = originalDateNow + rpcServer.stop(true) + } + }) + + it('keeps legacy nonce check-and-record storage working', async () => { + const request = await createSignedRequest() + const usedNonces = new Set() + let nonceChecks = 0 + let recordCalls = 0 + let lookupCalls = 0 + const events: Array> = [] + const storage: AgentKitStorage = { + async tryIncrementUsage() { + return true + }, + async hasUsedNonce(nonce) { + nonceChecks += 1 + return usedNonces.has(nonce) + }, + async recordNonce(nonce) { + recordCalls += 1 + usedNonces.add(nonce) + }, + } + const hooks = createAgentkitHooks({ + agentBook: { + async lookupHuman() { + lookupCalls += 1 + return 'human-1' + }, + }, + storage, + onEvent: event => events.push(event as Record), + }) + const context = { + adapter: createAdapter(request.url, request.header), + path: request.path, + } + + expect(await hooks.requestHook(context)).toEqual({ grantAccess: true }) + expect(await hooks.requestHook(context)).toBeUndefined() + expect(nonceChecks).toBe(2) + expect(recordCalls).toBe(1) + expect(lookupCalls).toBe(1) + expect(events.map(event => event.type)).toEqual(['agent_verified', 'validation_failed']) + }) + it('uses tryIncrementUsage to grant free-trial access', async () => { const request = await createSignedRequest() const usageCalls: Array<{ endpoint: string; humanId: string; limit: number }> = [] diff --git a/x402/tests/storage.test.ts b/x402/tests/storage.test.ts new file mode 100644 index 0000000..1d19a68 --- /dev/null +++ b/x402/tests/storage.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test' +import { InMemoryAgentKitStorage } from '../src/storage' + +describe('InMemoryAgentKitStorage', () => { + it('consumes each unexpired nonce once', async () => { + const storage = new InMemoryAgentKitStorage() + const expiresAt = new Date(Date.now() + 60_000) + + expect(await storage.consumeNonce('nonce-1', expiresAt)).toBe(true) + expect(await storage.consumeNonce('nonce-1', expiresAt)).toBe(false) + }) + + it('rejects expired nonce records', async () => { + const storage = new InMemoryAgentKitStorage() + + expect(await storage.consumeNonce('nonce-1', new Date(Date.now() - 1))).toBe(false) + expect(await storage.consumeNonce('nonce-1', new Date(Date.now() + 60_000))).toBe(true) + }) + + it('prunes consumed nonces after their challenge validity window', async () => { + const originalDateNow = Date.now + let currentTime = originalDateNow() + const storage = new InMemoryAgentKitStorage() + + Date.now = () => currentTime + try { + expect(await storage.consumeNonce('nonce-1', new Date(currentTime + 1_000))).toBe(true) + currentTime += 1_001 + expect(await storage.consumeNonce('nonce-1', new Date(currentTime + 1_000))).toBe(true) + } finally { + Date.now = originalDateNow + } + }) +})