feat: security hardening, wechat pay v3 platform cert integration, companion certification revocation, and audit closed-loop
这个提交包含在:
215
server/src/core/pay/wechat-pay-v3.ts
普通文件
215
server/src/core/pay/wechat-pay-v3.ts
普通文件
@@ -0,0 +1,215 @@
|
||||
// 已读取AGENTS.md
|
||||
/**
|
||||
* 微信支付 V3 (微信支付APIv3) 直连商户服务端客户端
|
||||
* - 使用商户 API 私钥 (apiclient_key.pem) 对请求做 RSA-SHA256 签名
|
||||
* - 使用微信支付平台公钥/平台证书 对回调做验签
|
||||
* - 使用 APIv3 密钥 对回调 resource 做 AES-256-GCM 解密
|
||||
* 该模块为纯函数/无状态,便于离线单元校验与复用。
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
|
||||
export interface WechatPayConfig {
|
||||
appId: string;
|
||||
mchId: string;
|
||||
apiV3Key: string;
|
||||
serialNo: string;
|
||||
privateKey: string;
|
||||
platformPublicKey?: string;
|
||||
platformPublicKeyId?: string;
|
||||
notifyUrl?: string;
|
||||
}
|
||||
|
||||
const WXPAY_HOST = 'api.mch.weixin.qq.com';
|
||||
|
||||
/** 解析微信支付平台公钥:兼容「公钥 PEM」与「平台证书 PEM」两种输入 */
|
||||
export function resolvePlatformKey(platformKeyOrCert?: string): crypto.KeyObject | null {
|
||||
const pem = String(platformKeyOrCert || '').trim();
|
||||
if (!pem) return null;
|
||||
try {
|
||||
return crypto.createPublicKey(pem);
|
||||
} catch (_) {
|
||||
// 若为 X509 证书 PEM,则提取其中的公钥
|
||||
try {
|
||||
return new crypto.X509Certificate(pem).publicKey;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成微信支付 V3 请求的 Authorization 头 */
|
||||
export function buildAuthorization(opts: {
|
||||
method: string;
|
||||
urlPath: string;
|
||||
body: string;
|
||||
cfg: WechatPayConfig;
|
||||
}): string {
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const nonceStr = crypto.randomBytes(16).toString('hex');
|
||||
const message = `${opts.method}\n${opts.urlPath}\n${timestamp}\n${nonceStr}\n${opts.body}\n`;
|
||||
const signature = crypto
|
||||
.createSign('RSA-SHA256')
|
||||
.update(message)
|
||||
.sign(opts.cfg.privateKey, 'base64');
|
||||
return (
|
||||
`WECHATPAY2-SHA256-RSA2048 ` +
|
||||
`mchid="${opts.cfg.mchId}",` +
|
||||
`nonce_str="${nonceStr}",` +
|
||||
`signature="${signature}",` +
|
||||
`timestamp="${timestamp}",` +
|
||||
`serial_no="${opts.cfg.serialNo}"`
|
||||
);
|
||||
}
|
||||
|
||||
/** 底层 HTTPS 请求,返回状态码 / 响应头 / 原始文本 */
|
||||
export function httpsRequest(opts: {
|
||||
method: string;
|
||||
urlPath: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
}): Promise<{ statusCode: number; headers: any; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
{
|
||||
method: opts.method,
|
||||
hostname: WXPAY_HOST,
|
||||
path: opts.urlPath,
|
||||
headers: opts.headers,
|
||||
},
|
||||
(resp) => {
|
||||
let data = '';
|
||||
resp.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
resp.on('end', () =>
|
||||
resolve({ statusCode: resp.statusCode || 0, headers: resp.headers, body: data })
|
||||
);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (opts.body) req.write(opts.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** JSAPI 统一下单,返回 prepay_id(并为小程序返回支付参数) */
|
||||
export async function createJsapiPrepay(
|
||||
cfg: WechatPayConfig,
|
||||
params: { outTradeNo: string; description: string; amountTotalCents: number; openid: string }
|
||||
): Promise<string> {
|
||||
const urlPath = '/v3/pay/transactions/jsapi';
|
||||
const body = JSON.stringify({
|
||||
appid: cfg.appId,
|
||||
mchid: cfg.mchId,
|
||||
description: params.description,
|
||||
out_trade_no: params.outTradeNo,
|
||||
notify_url: cfg.notifyUrl,
|
||||
amount: { total: params.amountTotalCents, currency: 'CNY' },
|
||||
payer: { openid: params.openid },
|
||||
});
|
||||
const authorization = buildAuthorization({ method: 'POST', urlPath, body, cfg });
|
||||
const resp = await httpsRequest({
|
||||
method: 'POST',
|
||||
urlPath,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: authorization,
|
||||
'User-Agent': 'peiwan-server/1.0',
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (resp.statusCode >= 400) {
|
||||
throw new Error(`微信支付 JSAPI 下单失败 [${resp.statusCode}]: ${resp.body}`);
|
||||
}
|
||||
const parsed = JSON.parse(resp.body || '{}');
|
||||
if (!parsed.prepay_id) {
|
||||
throw new Error(`微信支付未返回 prepay_id: ${resp.body}`);
|
||||
}
|
||||
return parsed.prepay_id as string;
|
||||
}
|
||||
|
||||
/** 使用 prepay_id 生成小程序 wx.requestPayment 所需参数并签名 */
|
||||
export function buildJsapiPayParams(
|
||||
cfg: WechatPayConfig,
|
||||
prepayId: string
|
||||
): { timeStamp: string; nonceStr: string; package: string; signType: 'RSA'; paySign: string } {
|
||||
const timeStamp = String(Math.floor(Date.now() / 1000));
|
||||
const nonceStr = crypto.randomBytes(16).toString('hex');
|
||||
const packageStr = `prepay_id=${prepayId}`;
|
||||
const message = `${cfg.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`;
|
||||
const paySign = crypto
|
||||
.createSign('RSA-SHA256')
|
||||
.update(message)
|
||||
.sign(cfg.privateKey, 'base64');
|
||||
return { timeStamp, nonceStr, package: packageStr, signType: 'RSA', paySign };
|
||||
}
|
||||
|
||||
/** 主动查单(用于支付成功后及时对账,避免仅依赖异步回调) */
|
||||
export async function queryOrderByOutTradeNo(
|
||||
cfg: WechatPayConfig,
|
||||
outTradeNo: string
|
||||
): Promise<any> {
|
||||
const urlPath = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}?mchid=${encodeURIComponent(cfg.mchId)}`;
|
||||
const authorization = buildAuthorization({ method: 'GET', urlPath, body: '', cfg });
|
||||
const resp = await httpsRequest({
|
||||
method: 'GET',
|
||||
urlPath,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: authorization,
|
||||
'User-Agent': 'peiwan-server/1.0',
|
||||
},
|
||||
});
|
||||
if (resp.statusCode >= 400) {
|
||||
throw new Error(`微信支付查单失败 [${resp.statusCode}]: ${resp.body}`);
|
||||
}
|
||||
return JSON.parse(resp.body || '{}');
|
||||
}
|
||||
|
||||
/** 校验微信支付回调签名(使用平台公钥/平台证书),时间戳容差 5 分钟 */
|
||||
export function verifyNotifySignature(params: {
|
||||
cfg: WechatPayConfig;
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
body: string;
|
||||
signature: string;
|
||||
serial: string;
|
||||
}): boolean {
|
||||
const { cfg, timestamp, nonce, body, signature } = params;
|
||||
if (!timestamp || !nonce || !signature) return false;
|
||||
const platformKey = resolvePlatformKey(cfg.platformPublicKey);
|
||||
if (!platformKey) return false;
|
||||
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
|
||||
const message = `${timestamp}\n${nonce}\n${body}\n`;
|
||||
try {
|
||||
return crypto
|
||||
.createVerify('RSA-SHA256')
|
||||
.update(message)
|
||||
.verify(platformKey, signature, 'base64');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解密回调 resource(AES-256-GCM,key 为 32 位 APIv3 密钥) */
|
||||
export function decryptNotifyResource(
|
||||
apiV3Key: string,
|
||||
resource: { ciphertext: string; nonce: string; associated_data?: string }
|
||||
): any {
|
||||
const key = Buffer.from(String(apiV3Key), 'utf8');
|
||||
if (key.length !== 32) {
|
||||
throw new Error('APIv3 密钥长度必须为 32 位');
|
||||
}
|
||||
const data = Buffer.from(resource.ciphertext, 'base64');
|
||||
const authTag = data.subarray(data.length - 16);
|
||||
const ciphertext = data.subarray(0, data.length - 16);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(resource.nonce, 'utf8'));
|
||||
decipher.setAuthTag(authTag);
|
||||
if (resource.associated_data) {
|
||||
decipher.setAAD(Buffer.from(resource.associated_data, 'utf8'));
|
||||
}
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return JSON.parse(decrypted.toString('utf8'));
|
||||
}
|
||||
@@ -13,6 +13,16 @@ export interface SecurityCheckResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 远端内容安全检测器签名(由服务端启动时注入真实的微信 msgSecCheck 调用)。
|
||||
* 返回 null 表示远端不可用/未配置,调用方应降级为本地词库结果。
|
||||
*/
|
||||
export type RemoteTextChecker = (
|
||||
text: string,
|
||||
openid?: string,
|
||||
scene?: number
|
||||
) => Promise<SecurityCheckResult | null>;
|
||||
|
||||
export class ContentSecurityService {
|
||||
// 敏感词黑名单库(按涉黄低俗、涉政暴恐、欺诈引流、违规黑产分类)
|
||||
private static readonly SENSITIVE_KEYWORDS: { [category: string]: string[] } = {
|
||||
@@ -33,6 +43,20 @@ export class ContentSecurityService {
|
||||
// 动态自定义敏感词列表(支持管理后台增删改并实时热生效)
|
||||
private customKeywords: string[] = [];
|
||||
|
||||
// 远端真实审核器(微信官方 security.msgSecCheck),未注入时仅使用本地词库
|
||||
private remoteChecker?: RemoteTextChecker;
|
||||
|
||||
/**
|
||||
* 注入/替换远端真实内容安全审核器(微信 msgSecCheck)
|
||||
*/
|
||||
public setRemoteChecker(checker: RemoteTextChecker): void {
|
||||
this.remoteChecker = checker;
|
||||
}
|
||||
|
||||
public hasRemoteChecker(): boolean {
|
||||
return typeof this.remoteChecker === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* 载入或更新动态敏感词黑名单
|
||||
*/
|
||||
@@ -49,6 +73,27 @@ export class ContentSecurityService {
|
||||
return [...this.customKeywords];
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步内容安全检测(真实对接微信 security.msgSecCheck)
|
||||
* 双层防御:先跑本地词库即时拦截;本地放行且已注入远端审核器时,
|
||||
* 再调用微信官方机器审核。远端异常/未配置时降级为本地结果(fail-open),避免阻断正常业务。
|
||||
* @param text 待检测文本
|
||||
* @param openid 用户微信 openid(供微信官方溯源,强烈建议传入)
|
||||
* @param scene 场景值:1 资料;2 评论;3 论坛;4 社交日志
|
||||
*/
|
||||
public async checkTextAsync(text: string, openid?: string, scene?: number): Promise<SecurityCheckResult> {
|
||||
const local = this.checkText(text, openid);
|
||||
if (!local.pass) return local;
|
||||
if (typeof this.remoteChecker !== 'function') return local;
|
||||
try {
|
||||
const remote = await this.remoteChecker(text, openid, scene);
|
||||
if (remote) return remote;
|
||||
} catch (_) {
|
||||
// 远端异常时降级为本地词库结果
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文本内容安全 (security.msgSecCheck)
|
||||
* @param text 待检测文本(支持动态、评论、活动标题、个人简介等)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 小程序用户会话 token(HMAC-SHA256 签名,无状态)。
|
||||
* 目的是让服务端在资金/敏感写接口上以「服务端签发并验签的身份」为准,
|
||||
* 杜绝客户端通过伪造 userId 冒充他人。
|
||||
*
|
||||
* token 结构: base64url(JSON{uid,ts}) + "." + base64url(HMAC_SHA256(payload, secret))
|
||||
*/
|
||||
|
||||
function base64url(input: Buffer | string): string {
|
||||
return Buffer.from(input).toString('base64url');
|
||||
}
|
||||
|
||||
export function signUserToken(
|
||||
userId: number,
|
||||
secret: string,
|
||||
ttlMs: number,
|
||||
now: number = Date.now()
|
||||
): string {
|
||||
const payload = base64url(JSON.stringify({ uid: Number(userId), ts: Number(now) }));
|
||||
const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
export function verifyUserToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
ttlMs: number,
|
||||
now: number = Date.now()
|
||||
): number | null {
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
const [payload, sig] = parts;
|
||||
if (!payload || !sig) return null;
|
||||
|
||||
const expected = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
||||
const sigBuf = Buffer.from(sig);
|
||||
const expBuf = Buffer.from(expected);
|
||||
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return null;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
const uid = Number(data.uid);
|
||||
const ts = Number(data.ts);
|
||||
if (!uid || !ts) return null;
|
||||
if (now - ts > ttlMs) return null;
|
||||
// 拒绝明显来自未来(时钟漂移/伪造)的 token
|
||||
if (ts - now > 5 * 60 * 1000) return null;
|
||||
return uid;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户