feat: security hardening, wechat pay v3 platform cert integration, companion certification revocation, and audit closed-loop

这个提交包含在:
xcbyy
2026-10-16 17:38:17 +08:00
父节点 3bf38db8b6
当前提交 78117bb4e1
共有 58 个文件被更改,包括 12971 次插入 和 952 次删除

查看文件

@@ -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;
}
}