98 lines
2.9 KiB
TypeScript
98 lines
2.9 KiB
TypeScript
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"),
|
|
};
|
|
}
|