111 lines
4.1 KiB
TypeScript
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;
|
||
|
|
}
|
||
|
|
}
|