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 { return Buffer.from(JSON.stringify(params)).toString("base64"); } export function decodeParams(merchantParams: string): Record { // 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): 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 = { 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; } }