import { Request, Response } from "express"; import fs from "fs"; import path from "path"; import crypto from "crypto"; import db from "../db"; import { get, set } from "lodash"; import { hashToken } from "../middleware/auth"; const KEY_PATH = process.env.PRIVATE_KEY_PATH || path.join(__dirname, "../../secrets/key.pem"); const ALT_KEY_PATH = path.join(__dirname, "../../public/mandiri/midsuit.pem"); function loadPrivateKeyFromPath(): Buffer | null { try { // prefer a user-provided midsuit.pem in public/mandiri if present if (fs.existsSync(ALT_KEY_PATH)) return fs.readFileSync(ALT_KEY_PATH); if (fs.existsSync(KEY_PATH)) return fs.readFileSync(KEY_PATH); return null; } catch (err) { return null; } } // Format timestamp like: 2025-10-17T14:23:05+07:00 (yyyy-MM-dd'T'HH:mm:ss±HH:MM) function formatTimestamp(d = new Date()): string { const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0"); const min = String(d.getMinutes()).padStart(2, "0"); const ss = String(d.getSeconds()).padStart(2, "0"); const offsetMinutes = -d.getTimezoneOffset(); const sign = offsetMinutes >= 0 ? "+" : "-"; const abs = Math.abs(offsetMinutes); const oh = String(Math.floor(abs / 60)).padStart(2, "0"); const om = String(abs % 60).padStart(2, "0"); return `${yyyy}-${mm}-${dd}T${hh}:${min}:${ss}${sign}${oh}:${om}`; } // Stable deterministic JSON stringify (keys sorted) for minify function stableStringify(value: any): string { if (value === null) return "null"; if (typeof value === "string") return JSON.stringify(value); if (typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) { return `[` + value.map((v) => stableStringify(v)).join(",") + `]`; } if (typeof value === "object") { const keys = Object.keys(value).sort(); return ( "{" + keys .map( (k) => `${JSON.stringify(k)}:${stableStringify((value as any)[k])}` ) .join(",") + "}" ); } return JSON.stringify(value); } function sha256HexLower(input: string) { return crypto .createHash("sha256") .update(input, "utf8") .digest("hex") .toLowerCase(); } /** * Generate RSA-SHA256 signature for the string: `${timestamp}|${rawBody}`. * * Behavior: * - If `privateKeyPem` is provided in the JSON body it will be used (PEM text). * - Otherwise the server will attempt to load PRIVATE_KEY_PATH on disk. * - If only a `.cer` (public certificate) is supplied the API returns an * explicit error because a public certificate cannot be used to create a * signature. */ export async function generateSignature(req: Request, res: Response) { try { const clientId = (req.body && (req.body.clientId || req.body.client_id)) || req.query.clientId || req.query.client_id; const data = req.body || {}; const signature = await createSignature({ client_id: clientId, data, passphrase: get(req.body, "passphrase"), pattern: get(req.body, "pattern"), }); return res.json({ ...signature, algorithm: "RSA-SHA256", }); } catch (err: any) { console.error("generateSignature error", err); return res.status(500).json({ error: err.message || "internal error" }); } } export async function createSignature({ client_id, data, pattern, passphrase, }: { client_id: string; data: any; pattern: string; passphrase?: string; }) { if (data?.accesstoken) { const session = await db.sessions.findFirst({ where: { token_hash: hashToken(data.accesstoken) }, include: { users: true }, }); if (!session) { throw new Error("Invalid access token; session not found"); } // expired check if (session.expires_at) { const exp = new Date(session.expires_at); if (exp.getTime() < Date.now()) { throw new Error("Access token expired"); } } client_id = session.users.clientbank_id; } if (!client_id) throw new Error("client_id is required for signing"); const user = await db.users.findFirst({ where: { clientbank_id: client_id }, }); if (!user) throw new Error("Invalid client_id; user not found"); const timestamp = get(data, "timestamp") || formatTimestamp(); if (!user.private_key_file) { throw new Error("No private key file associated with the user"); } const dir = path.join(__dirname, "..", "..", user.private_key_file); if (!fs.existsSync(dir)) { throw new Error(`Private key file not found`); } const privateKey: Buffer = fs.readFileSync(dir); // example pattern: "{client_id}|{timestamp}|{order_id}|{amount}" let stringToSign = pattern; let dataResult = {} as any; const regex = /\{(\w+)\}/g; let match; while ((match = regex.exec(pattern)) !== null) { const key = match[1]; const value = key === "client_id" ? client_id : key === "timestamp" ? timestamp : get(data, key); if (typeof value === "object" || Array.isArray(value)) { // Lowercase(HexEncode(SHA-256(minify(RequestBody)))) const minified = stableStringify(value); const hash = sha256HexLower(minified); stringToSign = stringToSign.replace(match[0], hash); set(dataResult, key, hash); continue; } stringToSign = stringToSign.replace(match[0], String(value)); set(dataResult, key, String(value)); } const signer = crypto.createSign("RSA-SHA256"); signer.update(stringToSign); signer.end(); // If a passphrase is provided, pass an object to signer.sign. Ensure Buffer -> string let signatureBase64: string; if (passphrase) { const keyStr = Buffer.isBuffer(privateKey) ? privateKey.toString("utf8") : String(privateKey); signatureBase64 = signer.sign({ key: keyStr, passphrase }, "base64"); } else { const keyArg: any = Buffer.isBuffer(privateKey) ? privateKey.toString("utf8") : privateKey; signatureBase64 = signer.sign(keyArg, "base64"); } const transaction = [] as any[]; transaction.push( db.auth_logs.create({ data: { user_id: user.user_id, action: "signature", status: "success", message: "user created signature", }, }) ); transaction.push( db.sessions.create({ data: { user_id: user.user_id, signature: signatureBase64, expires_at: new Date(new Date(timestamp).getTime() + 15 * 60 * 1000), // 15 minutes }, }) ); await db.$transaction(transaction); return { signature: signatureBase64, expires_at: formatTimestamp( new Date(new Date(timestamp).getTime() + 15 * 60 * 1000) ), // 15 minutes ...dataResult, stringToSign, }; } export async function verifySignature(req: Request, res: Response) { try { // timestamp may be in body or query; default to now if missing (verification should fail if timestamp mismatch) const timestamp = req.body.timestamp || req.query.timestamp || String(Math.floor(Date.now() / 1000)); const raw = (req as any).rawBody ? (req as any).rawBody.toString("utf8") : JSON.stringify(req.body.payload || req.body || {}); const signatureBase64 = req.body.signature || req.headers["x-signature"]; const suppliedCer = req.body && req.body.cer; const suppliedCerPath = req.body && req.body.cerPath; if (!signatureBase64) { return res.status(400).json({ error: "Missing signature (body.signature or X-Signature header)", }); } // Load certificate: prefer raw cer text in body, then a provided path, then the default public file let cerPem: Buffer | null = null; if (suppliedCer) { // accept either base64 or raw PEM const text = String(suppliedCer).trim(); if (text.includes("-----BEGIN CERTIFICATE-----")) { cerPem = Buffer.from(text, "utf8"); } else { // maybe base64-encoded DER try { cerPem = Buffer.from(text, "base64"); } catch { cerPem = Buffer.from(text, "utf8"); } } } else if (suppliedCerPath) { try { if (fs.existsSync(suppliedCerPath)) cerPem = fs.readFileSync(suppliedCerPath); } catch (err) { // fall through } } else { // default public cert bundled in repo const defaultPath = path.join( __dirname, "../../public/mandiri/midsuit.cer" ); try { if (fs.existsSync(defaultPath)) cerPem = fs.readFileSync(defaultPath); } catch (err) { // ignore } } if (!cerPem) { return res .status(400) .json({ error: "No public certificate available for verification" }); } // crypto.verify expects a KeyObject or PEM string; ensure we pass PEM string // If cert is DER (binary), convert to PEM wrapper let publicKeyPem: string; const cerStr = cerPem.toString("utf8"); if (cerStr.includes("-----BEGIN CERTIFICATE-----")) { publicKeyPem = cerStr; } else { // assume DER base64 -> wrap const b64 = cerPem.toString("base64"); publicKeyPem = `-----BEGIN CERTIFICATE-----\n${ b64.match(/.{1,64}/g)?.join("\n") || b64 }\n-----END CERTIFICATE-----`; } const stringToVerify = `${timestamp}|${raw}`; const verifier = crypto.createVerify("RSA-SHA256"); verifier.update(stringToVerify); verifier.end(); const sigBuffer = Buffer.isBuffer(signatureBase64) ? signatureBase64 : Buffer.from(String(signatureBase64), "base64"); // Use the certificate PEM directly as the public key; Node will extract the public key const valid = verifier.verify(publicKeyPem, sigBuffer); return res.json({ valid, details: { algorithm: "RSA-SHA256", timestamp } }); } catch (err: any) { console.error("verifySignature error", err); return res.status(500).json({ error: err.message || "internal error" }); } }