rspace-online/shared/x402/crdt-scheme.ts

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}` };
}
}