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
5 changes: 5 additions & 0 deletions .changeset/atomic-nonce-consumption.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion skills/integrate-agentkit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 22 additions & 3 deletions x402/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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`,
Comment on lines +450 to +454

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject expired timestamps in the PostgreSQL example

The documented implementation inserts whenever the nonce is absent, even if expiresAt has already passed. Near the validity boundary, two requests can both pass the hook's pre-check, the first insert can then be removed by expiry cleanup, and a delayed second insert can also return success, allowing both replays through. Since AgentKitStorage now requires implementations to reject expired records, make the atomic statement condition insertion on expiresAt being later than the database's current time.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 607a36b. The PostgreSQL example now uses INSERT ... SELECT with WHERE $2::timestamptz > clock_timestamp() before ON CONFLICT, so a delayed expired request cannot recreate a nonce after cleanup. I also added a docs regression that guards the database-side predicate. The full 33-test suite and core/x402/CLI builds pass.

[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)`

Expand Down
30 changes: 28 additions & 2 deletions x402/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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, unknown>): string | null {
try {
if ('authorization' in payload) {
Expand Down
33 changes: 31 additions & 2 deletions x402/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,26 @@ export interface AgentKitStorage {
*/
tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise<boolean>

/**
* 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<boolean>

/** @deprecated Implement `consumeNonce` for atomic replay protection. */
hasUsedNonce?(nonce: string): Promise<boolean>
/** @deprecated Implement `consumeNonce` for atomic replay protection. */
recordNonce?(nonce: string): Promise<void>
}

export class InMemoryAgentKitStorage implements AgentKitStorage {
private usage = new Map<string, number>()
private nonces = new Set<string>()
private nonces = new Map<string, number>()

async tryIncrementUsage(endpoint: string, humanId: string, limit: number): Promise<boolean> {
const key = `${endpoint}:${humanId}`
Expand All @@ -24,11 +37,27 @@ export class InMemoryAgentKitStorage implements AgentKitStorage {
return true
}

async consumeNonce(nonce: string, expiresAt: Date): Promise<boolean> {
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<boolean> {
this.pruneExpiredNonces(Date.now())
return this.nonces.has(nonce)
}

async recordNonce(nonce: string): Promise<void> {
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)
}
}
}
14 changes: 14 additions & 0 deletions x402/tests/docs.test.ts
Original file line number Diff line number Diff line change
@@ -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()')
})
})
Loading