portal-payment-be/src/controllers/signController.ts

312 lines
9.9 KiB
TypeScript

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" });
}
}