98 lines
3.0 KiB
TypeScript
98 lines
3.0 KiB
TypeScript
import { createDecipheriv, createVerify, timingSafeEqual } from 'crypto';
|
||
|
||
export type WechatPayNotifyResource = {
|
||
transaction_id: string;
|
||
out_trade_no: string;
|
||
trade_state: string;
|
||
trade_state_desc?: string;
|
||
amount?: { total?: number; payer_total?: number };
|
||
};
|
||
|
||
export type WechatRefundNotifyResource = {
|
||
refund_id: string;
|
||
out_refund_no: string;
|
||
transaction_id?: string;
|
||
out_trade_no?: string;
|
||
refund_status: 'SUCCESS' | 'PROCESSING' | 'ABNORMAL' | 'CLOSED';
|
||
amount?: { refund?: number; total?: number; payer_refund?: number; payer_total?: number };
|
||
};
|
||
|
||
export type WechatPayNotifyEnvelope = {
|
||
id: string;
|
||
create_time: string;
|
||
event_type: string;
|
||
resource_type: string;
|
||
summary: string;
|
||
resource: {
|
||
algorithm: string;
|
||
ciphertext: string;
|
||
associated_data?: string;
|
||
nonce: string;
|
||
original_type?: string;
|
||
};
|
||
};
|
||
|
||
export function decryptPayResource(
|
||
apiV3Key: string,
|
||
associatedData: string,
|
||
nonce: string,
|
||
ciphertext: string,
|
||
): WechatPayNotifyResource {
|
||
const key = Buffer.from(apiV3Key, 'utf8');
|
||
const buf = Buffer.from(ciphertext, 'base64');
|
||
const authTag = buf.subarray(buf.length - 16);
|
||
const data = buf.subarray(0, buf.length - 16);
|
||
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(nonce, 'utf8'));
|
||
if (associatedData) {
|
||
decipher.setAAD(Buffer.from(associatedData, 'utf8'));
|
||
}
|
||
decipher.setAuthTag(authTag);
|
||
const decoded = Buffer.concat([decipher.update(data), decipher.final()]);
|
||
return JSON.parse(decoded.toString('utf8')) as WechatPayNotifyResource;
|
||
}
|
||
|
||
export function verifyPaySignature(params: {
|
||
platformPublicKeyPem: string;
|
||
timestamp: string;
|
||
nonce: string;
|
||
body: string;
|
||
signature: string;
|
||
}): boolean {
|
||
const message = `${params.timestamp}\n${params.nonce}\n${params.body}\n`;
|
||
const verifier = createVerify('RSA-SHA256');
|
||
verifier.update(message);
|
||
verifier.end();
|
||
const ok = verifier.verify(params.platformPublicKeyPem, params.signature, 'base64');
|
||
if (!ok) return false;
|
||
const ts = Number(params.timestamp);
|
||
if (!Number.isFinite(ts)) return false;
|
||
const skewMs = Math.abs(Date.now() - ts * 1000);
|
||
return skewMs <= 5 * 60 * 1000;
|
||
}
|
||
|
||
export function safeEqual(a: string, b: string): boolean {
|
||
const ba = Buffer.from(a);
|
||
const bb = Buffer.from(b);
|
||
if (ba.length !== bb.length) return false;
|
||
return timingSafeEqual(ba, bb);
|
||
}
|
||
|
||
/**
|
||
* 规范化 .env / system_config 中的 PEM:
|
||
* - 去掉外层引号(DB/表单常把整段含引号写入)
|
||
* - 把字面量 \\n 转成真实换行
|
||
* OpenSSL 报 1E08010C DECODER unsupported 时多半是这两类污染。
|
||
*/
|
||
export function normalizePemEnv(raw: string | undefined | null): string {
|
||
if (!raw) return '';
|
||
let value = String(raw).trim();
|
||
if (
|
||
(value.startsWith('"') && value.endsWith('"')) ||
|
||
(value.startsWith("'") && value.endsWith("'"))
|
||
) {
|
||
value = value.slice(1, -1).trim();
|
||
}
|
||
value = value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
|
||
return value.trim();
|
||
}
|