feat: 企微回调服务基建(加解密+事件分发,默认关闭)
This commit is contained in:
97
lib/wecom-callback-crypto.ts
Normal file
97
lib/wecom-callback-crypto.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
|
||||
export class WecomCallbackCryptoError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status = 400) {
|
||||
super(message);
|
||||
this.name = "WecomCallbackCryptoError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWecomCallbackAesKey(encodingAesKey: string): Buffer {
|
||||
const key = String(encodingAesKey ?? "").trim();
|
||||
if (!/^[A-Za-z0-9]{43}$/.test(key)) {
|
||||
throw new WecomCallbackCryptoError(
|
||||
"企业微信回调 EncodingAESKey 非法(需 43 位字母数字)",
|
||||
500,
|
||||
);
|
||||
}
|
||||
return Buffer.from(`${key}=`, "base64");
|
||||
}
|
||||
|
||||
export function verifyWecomCallbackSignature(
|
||||
token: string,
|
||||
timestamp: string,
|
||||
nonce: string,
|
||||
encrypt: string,
|
||||
): string {
|
||||
return createHash("sha1")
|
||||
.update([token, timestamp, nonce, encrypt].sort().join(""))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function wecomSignaturesMatch(
|
||||
expected: string,
|
||||
received: string,
|
||||
): boolean {
|
||||
const a = Buffer.from(String(expected ?? ""));
|
||||
const b = Buffer.from(String(received ?? ""));
|
||||
if (a.length !== b.length || a.length === 0) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export function encryptWecomCallback(
|
||||
plainMessage: string,
|
||||
receiveId: string,
|
||||
encodingAesKey: string,
|
||||
): string {
|
||||
const key = resolveWecomCallbackAesKey(encodingAesKey);
|
||||
const random = randomBytes(16);
|
||||
const message = Buffer.from(String(plainMessage ?? ""), "utf8");
|
||||
const receive = Buffer.from(String(receiveId ?? ""), "utf8");
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(message.length, 0);
|
||||
const cipher = createCipheriv("aes-256-cbc", key, key.subarray(0, 16));
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(Buffer.concat([random, length, message, receive])),
|
||||
cipher.final(),
|
||||
]);
|
||||
return encrypted.toString("base64");
|
||||
}
|
||||
|
||||
export function decryptWecomCallback(
|
||||
encrypted: string,
|
||||
encodingAesKey: string,
|
||||
): { message: string; receiveId: string } {
|
||||
const key = resolveWecomCallbackAesKey(encodingAesKey);
|
||||
const data = Buffer.from(String(encrypted ?? "").trim(), "base64");
|
||||
if (data.length === 0 || data.length % 16 !== 0) {
|
||||
throw new WecomCallbackCryptoError("企业微信回调密文长度非法");
|
||||
}
|
||||
let raw: Buffer;
|
||||
try {
|
||||
const decipher = createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
|
||||
raw = Buffer.concat([decipher.update(data), decipher.final()]);
|
||||
} catch {
|
||||
throw new WecomCallbackCryptoError("企业微信回调密文解密失败");
|
||||
}
|
||||
if (raw.length < 20) {
|
||||
throw new WecomCallbackCryptoError("企业微信回调明文长度非法");
|
||||
}
|
||||
const messageLength = raw.readUInt32BE(16);
|
||||
if (messageLength <= 0 || 20 + messageLength > raw.length) {
|
||||
throw new WecomCallbackCryptoError("企业微信回调消息长度字段非法");
|
||||
}
|
||||
return {
|
||||
message: raw.subarray(20, 20 + messageLength).toString("utf8"),
|
||||
receiveId: raw.subarray(20 + messageLength).toString("utf8"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user