Skip to content

Commit 2dd5b4f

Browse files
Jpatchingclaude
andcommitted
fix: server-side Ed25519 signature verification for x402 payments
Replace blind trust of client-supplied payer identity with cryptographic verification. The server now reconstructs the canonical payment message, SHA-256 hashes it, and verifies the Ed25519 signature against the claimed payer's public key before setting req.wallet. Also validates timestamp freshness (10min window) and payment amount/recipient against server config. Removes unused Coinbase facilitator dependency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 398b18e commit 2dd5b4f

1 file changed

Lines changed: 139 additions & 42 deletions

File tree

backend/src/services/x402.ts

Lines changed: 139 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { Request, Response, NextFunction } from 'express';
2+
import nacl from 'tweetnacl';
3+
import bs58 from 'bs58';
4+
import { createHash } from 'crypto';
25

36
// x402 Payment Protocol — Solana USDC server middleware
4-
// Spec: https://www.x402.org/
5-
// Facilitator: https://x402.coinbase.com
7+
// Self-verified Ed25519 signatures (no external facilitator needed)
8+
// Spec inspiration: https://www.x402.org/
69

7-
const COINBASE_FACILITATOR_URL = 'https://x402.coinbase.com';
810
const USDC_DECIMALS = 6;
11+
const MAX_PAYMENT_AGE_SECONDS = 600; // 10 minutes
912

1013
export interface X402ServerConfig {
1114
payToWallet: string;
1215
network: 'solana' | 'solana-devnet';
13-
facilitatorUrl?: string;
1416
priceUsd: number;
1517
description?: string;
1618
}
@@ -81,30 +83,122 @@ export function send402(res: Response, config: X402ServerConfig, resource: strin
8183
});
8284
}
8385

84-
async function verifyWithFacilitator(payload: any, facilitatorUrl: string): Promise<boolean> {
86+
interface X402PaymentPayload {
87+
paymentOption: {
88+
scheme: string;
89+
network: string;
90+
asset: string;
91+
maxAmountRequired: string;
92+
payTo: string;
93+
validUntil?: number;
94+
};
95+
signature: string; // base58 Ed25519 signature
96+
payer: string; // base58 public key
97+
nonce: string;
98+
timestamp: number;
99+
}
100+
101+
/**
102+
* Verify x402 payment payload by checking Ed25519 signature server-side.
103+
* 1. Reconstruct the canonical message from the payment option fields
104+
* 2. SHA-256 hash it
105+
* 3. Verify Ed25519 signature against the claimed payer's public key
106+
* 4. Check timestamp freshness and payment option matches our config
107+
*
108+
* Returns the verified payer public key (base58) or null if invalid.
109+
*/
110+
function verifyPaymentSignature(payload: X402PaymentPayload, config: X402ServerConfig): string | null {
85111
try {
86-
const response = await fetch(`${facilitatorUrl}/verify`, {
87-
method: 'POST',
88-
headers: { 'Content-Type': 'application/json' },
89-
body: JSON.stringify(payload),
112+
const { paymentOption, signature, payer, nonce, timestamp } = payload;
113+
114+
// Validate required fields
115+
if (!paymentOption || !signature || !payer || !nonce || !timestamp) {
116+
console.error('[x402] Missing required fields in payment payload');
117+
return null;
118+
}
119+
120+
// Check timestamp freshness (reject stale payments)
121+
const now = Math.floor(Date.now() / 1000);
122+
if (Math.abs(now - timestamp) > MAX_PAYMENT_AGE_SECONDS) {
123+
console.error(`[x402] Payment timestamp too old/future: ${timestamp} vs ${now}`);
124+
return null;
125+
}
126+
127+
// Verify payment option matches our config
128+
if (paymentOption.payTo !== config.payToWallet) {
129+
console.error(`[x402] payTo mismatch: ${paymentOption.payTo} !== ${config.payToWallet}`);
130+
return null;
131+
}
132+
133+
const expectedAmount = usdToLamports(config.priceUsd);
134+
if (BigInt(paymentOption.maxAmountRequired) < BigInt(expectedAmount)) {
135+
console.error(`[x402] Amount too low: ${paymentOption.maxAmountRequired} < ${expectedAmount}`);
136+
return null;
137+
}
138+
139+
// Reconstruct the canonical message (must match client-side construction)
140+
const message = JSON.stringify({
141+
scheme: paymentOption.scheme,
142+
network: paymentOption.network,
143+
asset: paymentOption.asset,
144+
amount: paymentOption.maxAmountRequired,
145+
payTo: paymentOption.payTo,
146+
nonce,
147+
timestamp,
148+
validUntil: paymentOption.validUntil ?? timestamp + 300,
90149
});
91-
if (!response.ok) return false;
92-
const result = await response.json() as { valid?: boolean; settled?: boolean };
93-
return result.valid === true && (result.settled === true || result.settled === undefined);
150+
151+
// SHA-256 hash of the message
152+
const messageBytes = new TextEncoder().encode(message);
153+
const messageHash = createHash('sha256').update(messageBytes).digest();
154+
155+
// Decode payer public key and signature from base58
156+
let publicKeyBytes: Uint8Array;
157+
let signatureBytes: Uint8Array;
158+
try {
159+
publicKeyBytes = bs58.decode(payer);
160+
signatureBytes = bs58.decode(signature);
161+
} catch {
162+
console.error('[x402] Failed to decode base58 payer/signature');
163+
return null;
164+
}
165+
166+
if (publicKeyBytes.length !== 32) {
167+
console.error(`[x402] Invalid public key length: ${publicKeyBytes.length}`);
168+
return null;
169+
}
170+
171+
if (signatureBytes.length !== 64) {
172+
console.error(`[x402] Invalid signature length: ${signatureBytes.length}`);
173+
return null;
174+
}
175+
176+
// Verify Ed25519 signature
177+
const valid = nacl.sign.detached.verify(
178+
new Uint8Array(messageHash),
179+
signatureBytes,
180+
publicKeyBytes,
181+
);
182+
183+
if (!valid) {
184+
console.error('[x402] Ed25519 signature verification failed');
185+
return null;
186+
}
187+
188+
// Signature verified — return the proven payer identity
189+
return payer;
94190
} catch (err) {
95-
console.error('[x402] Facilitator verification error:', err);
96-
return false;
191+
console.error('[x402] Signature verification error:', err);
192+
return null;
97193
}
98194
}
99195

100196
/**
101197
* x402 middleware for paid endpoints (agents/bots, no JWT required).
102-
* If X-PAYMENT header present → verify with Coinbase facilitator → allow.
198+
* If X-PAYMENT header present → verify Ed25519 signature server-side → allow.
103199
* Otherwise → 402 with USDC payment instructions.
104200
*/
105201
export function createX402Middleware(config: X402ServerConfig) {
106-
const facilitatorUrl = config.facilitatorUrl || COINBASE_FACILITATOR_URL;
107-
108202
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
109203
const paymentHeader = req.headers['x-payment'] as string | undefined;
110204

@@ -120,34 +214,37 @@ export function createX402Middleware(config: X402ServerConfig) {
120214
return;
121215
}
122216

217+
let payload: X402PaymentPayload;
123218
try {
124-
const payload = JSON.parse(Buffer.from(paymentHeader, 'base64').toString());
125-
126-
const verified = await verifyWithFacilitator(payload, facilitatorUrl);
127-
if (!verified) {
128-
res.status(402).json({ error: 'Payment verification failed. Please try again.' });
129-
return;
130-
}
131-
132-
// Track payment stats
133-
const endpoint = req.baseUrl + req.path;
134-
stats.totalPayments++;
135-
stats.totalRevenue += config.priceUsd;
136-
if (!stats.byEndpoint[endpoint]) {
137-
stats.byEndpoint[endpoint] = { count: 0, revenue: 0 };
138-
}
139-
stats.byEndpoint[endpoint].count++;
140-
stats.byEndpoint[endpoint].revenue += config.priceUsd;
141-
142-
console.log(`[x402] Payment received: ${endpoint} from ${payload.payer || 'unknown'} — $${config.priceUsd}`);
143-
144-
// Attach payer info for downstream usage tracking
145-
req.wallet = payload.payer || 'x402-anonymous';
146-
147-
next();
219+
payload = JSON.parse(Buffer.from(paymentHeader, 'base64').toString());
148220
} catch (err) {
149221
console.error('[x402] Payment parsing error:', err);
150-
res.status(400).json({ error: 'Invalid payment payload' });
222+
res.status(400).json({ error: 'Invalid payment payload — could not decode' });
223+
return;
151224
}
225+
226+
// Verify Ed25519 signature server-side (proves wallet ownership)
227+
const verifiedPayer = verifyPaymentSignature(payload, config);
228+
if (!verifiedPayer) {
229+
res.status(402).json({ error: 'Payment verification failed. Invalid signature or stale payment.' });
230+
return;
231+
}
232+
233+
// Track payment stats
234+
const endpoint = req.baseUrl + req.path;
235+
stats.totalPayments++;
236+
stats.totalRevenue += config.priceUsd;
237+
if (!stats.byEndpoint[endpoint]) {
238+
stats.byEndpoint[endpoint] = { count: 0, revenue: 0 };
239+
}
240+
stats.byEndpoint[endpoint].count++;
241+
stats.byEndpoint[endpoint].revenue += config.priceUsd;
242+
243+
console.log(`[x402] Payment verified: ${endpoint} from ${verifiedPayer} — $${config.priceUsd}`);
244+
245+
// Attach cryptographically verified payer identity
246+
req.wallet = verifiedPayer;
247+
248+
next();
152249
};
153250
}

0 commit comments

Comments
 (0)