312 lines
9.9 KiB
TypeScript
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" });
|
|
}
|
|
}
|