104 lines
3.1 KiB
TypeScript
104 lines
3.1 KiB
TypeScript
/**
|
|
* x402 CRDT payment scheme — server-side verifier.
|
|
*
|
|
* Verifies CRDT payment payloads: JWT signature, DID match,
|
|
* timestamp freshness, and nonce replay protection.
|
|
*/
|
|
|
|
// Lazy-import to avoid pulling EncryptID SDK at module level in shared/
|
|
let _verifyToken: ((token: string) => Promise<{ sub: string; did?: string; username?: string }>) | null = null;
|
|
|
|
export function setTokenVerifier(fn: (token: string) => Promise<{ sub: string; did?: string; username?: string }>) {
|
|
_verifyToken = fn;
|
|
}
|
|
|
|
export interface CrdtPaymentPayload {
|
|
scheme: "crdt";
|
|
jwtToken: string;
|
|
fromDid: string;
|
|
fromLabel?: string;
|
|
amount: number;
|
|
tokenId: string;
|
|
nonce: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
export interface CrdtVerifyResult {
|
|
valid: boolean;
|
|
error?: string;
|
|
fromDid?: string;
|
|
fromLabel?: string;
|
|
}
|
|
|
|
// In-memory nonce store with TTL (sufficient for single-instance deployment)
|
|
const usedNonces = new Map<string, number>();
|
|
const NONCE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
const TIMESTAMP_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
|
// Cleanup expired nonces every 5 minutes
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [nonce, ts] of usedNonces) {
|
|
if (now - ts > NONCE_TTL_MS) usedNonces.delete(nonce);
|
|
}
|
|
}, 5 * 60 * 1000).unref?.();
|
|
|
|
/**
|
|
* Verify a CRDT payment payload.
|
|
* Does NOT check balance or execute transfer — that's the middleware's job.
|
|
*/
|
|
export async function verifyCrdtPayment(
|
|
payload: CrdtPaymentPayload,
|
|
requiredAmount: number,
|
|
requiredTokenId: string,
|
|
): Promise<CrdtVerifyResult> {
|
|
// 1. Basic field validation
|
|
if (!payload.jwtToken || !payload.fromDid || !payload.nonce || !payload.timestamp) {
|
|
return { valid: false, error: "Missing required fields" };
|
|
}
|
|
|
|
if (payload.tokenId !== requiredTokenId) {
|
|
return { valid: false, error: `Wrong token: expected ${requiredTokenId}, got ${payload.tokenId}` };
|
|
}
|
|
|
|
if (payload.amount < requiredAmount) {
|
|
return { valid: false, error: `Insufficient amount: need ${requiredAmount}, got ${payload.amount}` };
|
|
}
|
|
|
|
// 2. Timestamp freshness
|
|
const age = Math.abs(Date.now() - payload.timestamp);
|
|
if (age > TIMESTAMP_WINDOW_MS) {
|
|
return { valid: false, error: "Payment timestamp too old or too far in future" };
|
|
}
|
|
|
|
// 3. Nonce replay protection
|
|
if (usedNonces.has(payload.nonce)) {
|
|
return { valid: false, error: "Nonce already used (replay detected)" };
|
|
}
|
|
|
|
// 4. JWT verification
|
|
if (!_verifyToken) {
|
|
return { valid: false, error: "Token verifier not configured" };
|
|
}
|
|
|
|
try {
|
|
const claims = await _verifyToken(payload.jwtToken);
|
|
const claimsDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
|
|
|
if (claimsDid !== payload.fromDid && claims.sub !== payload.fromDid) {
|
|
return { valid: false, error: "JWT DID does not match payment fromDid" };
|
|
}
|
|
|
|
// All checks passed — mark nonce as used
|
|
usedNonces.set(payload.nonce, Date.now());
|
|
|
|
return {
|
|
valid: true,
|
|
fromDid: claimsDid,
|
|
fromLabel: payload.fromLabel || claims.username || claimsDid,
|
|
};
|
|
} catch (e) {
|
|
return { valid: false, error: `JWT verification failed: ${(e as Error).message}` };
|
|
}
|
|
}
|