Files
argument.es/redsys.ts
Malin 2fac92356d feat: footer, auto-pause on no viewers, and Redsys question submissions
- Footer: add site-footer with one-liner + Cloud Host branding linking to cloudhost.es (mobile only, hidden on desktop)
- Auto-pause: game pauses automatically after AUTOPAUSE_DELAY_MS (default 60s) with no viewers connected; auto-resumes when first viewer connects; shows "Esperando espectadores…" in the UI
- Redsys: new /pregunta page lets viewers pay 1€ via Redsys to submit a fill-in question; submitted questions are used as prompts in the next round instead of AI generation; new redsys.ts implements HMAC_SHA256_V1 signing (3DES key derivation + HMAC-SHA256); notification endpoint at /api/redsys/notificacion handles server-to-server confirmation
- db.ts: questions table with createPendingQuestion / markQuestionPaid / getNextPendingQuestion / markQuestionUsed
- .env.sample: added AUTOPAUSE_DELAY_MS, PUBLIC_URL, REDSYS_* vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:03:30 +01:00

111 lines
4.1 KiB
TypeScript

import { createCipheriv, createHmac } from "node:crypto";
// ── Key derivation ────────────────────────────────────────────────────────────
// Redsys HMAC_SHA256_V1: derive a per-order signature key by 3DES-CBC encrypting
// the padded order ID with the merchant secret key.
function deriveKey(secretKeyBase64: string, orderId: string): Buffer {
const rawKey = Buffer.from(secretKeyBase64, "base64");
// 3DES-CBC requires exactly 24 bytes
const key24 = Buffer.alloc(24);
rawKey.copy(key24, 0, 0, Math.min(rawKey.length, 24));
// Pad order ID to a multiple of 8 bytes with zeros
const orderBuf = Buffer.from(orderId, "ascii");
const remainder = orderBuf.length % 8;
const paddedOrder =
remainder === 0 ? orderBuf : Buffer.concat([orderBuf, Buffer.alloc(8 - remainder)]);
// 3DES-CBC with 8-byte zero IV, no auto-padding
const iv = Buffer.alloc(8, 0);
const cipher = createCipheriv("des-ede3-cbc", key24, iv);
cipher.setAutoPadding(false);
return Buffer.concat([cipher.update(paddedOrder), cipher.final()]);
}
function normalizeBase64(s: string): string {
return s.replace(/-/g, "+").replace(/_/g, "/").replace(/=/g, "");
}
// ── Public API ────────────────────────────────────────────────────────────────
export function encodeParams(params: Record<string, string>): string {
return Buffer.from(JSON.stringify(params)).toString("base64");
}
export function decodeParams(merchantParams: string): Record<string, string> {
// Redsys sends back URL-safe Base64; normalize before decoding
const normalized = merchantParams.replace(/-/g, "+").replace(/_/g, "/");
return JSON.parse(Buffer.from(normalized, "base64").toString("utf8"));
}
export function sign(secretKeyBase64: string, orderId: string, data: string): string {
const derivedKey = deriveKey(secretKeyBase64, orderId);
return createHmac("sha256", derivedKey).update(data).digest("base64");
}
export function isPaymentApproved(decodedParams: Record<string, string>): boolean {
const code = parseInt(decodedParams["Ds_Response"] ?? "9999", 10);
return Number.isFinite(code) && code >= 0 && code <= 99;
}
export type RedsysConfig = {
secretKey: string;
merchantCode: string;
terminal: string;
isTest: boolean;
orderId: string;
amount: number; // in cents
urlOk: string;
urlKo: string;
merchantUrl: string;
productDescription?: string;
};
export function buildPaymentForm(config: RedsysConfig): {
tpvUrl: string;
merchantParams: string;
signature: string;
signatureVersion: string;
} {
const tpvUrl = config.isTest
? "https://sis-t.redsys.es:25443/sis/realizarPago"
: "https://sis.redsys.es/sis/realizarPago";
const params: Record<string, string> = {
DS_MERCHANT_AMOUNT: String(config.amount),
DS_MERCHANT_ORDER: config.orderId,
DS_MERCHANT_MERCHANTCODE: config.merchantCode,
DS_MERCHANT_CURRENCY: "978",
DS_MERCHANT_TRANSACTIONTYPE: "0",
DS_MERCHANT_TERMINAL: config.terminal,
DS_MERCHANT_URLOK: config.urlOk,
DS_MERCHANT_URLKO: config.urlKo,
DS_MERCHANT_MERCHANTURL: config.merchantUrl,
DS_MERCHANT_CONSUMERLANGUAGE: "001",
};
if (config.productDescription) {
params["DS_MERCHANT_PRODUCTDESCRIPTION"] = config.productDescription;
}
const merchantParams = encodeParams(params);
const signature = sign(config.secretKey, config.orderId, merchantParams);
return { tpvUrl, merchantParams, signature, signatureVersion: "HMAC_SHA256_V1" };
}
export function verifyNotification(
secretKeyBase64: string,
merchantParams: string,
receivedSignature: string,
): boolean {
try {
const decoded = decodeParams(merchantParams);
const orderId = decoded["Ds_Order"] ?? decoded["DS_MERCHANT_ORDER"] ?? "";
if (!orderId) return false;
const expectedSig = sign(secretKeyBase64, orderId, merchantParams);
return normalizeBase64(expectedSig) === normalizeBase64(receivedSignature);
} catch {
return false;
}
}