📄 aes.ts

/**
 * WeCom AES-256-CBC 加解密核心
 * Bot 和 Agent 模式共用
 */

import crypto from "node:crypto";
import { CRYPTO } from "../types/constants.js";

export function decodeEncodingAESKey(encodingAESKey: string): Buffer {
    const trimmed = encodingAESKey.trim();
    if (!trimmed) throw new Error("encodingAESKey missing");
    const withPadding = trimmed.endsWith("=") ? trimmed : `${trimmed}=`;
    const key = Buffer.from(withPadding, "base64");
    if (key.length !== CRYPTO.AES_KEY_LENGTH) {
        throw new Error(`invalid encodingAESKey (expected ${CRYPTO.AES_KEY_LENGTH} bytes, got ${key.length})`);
    }
    return key;
}

function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
    const mod = buf.length % blockSize;
    const pad = mod === 0 ? blockSize : blockSize - mod;
    const padByte = Buffer.from([pad]);
    return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
}

export function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer {
    if (buf.length === 0) throw new Error("invalid pkcs7 payload");
    const pad = buf[buf.length - 1]!;
    if (pad < 1 || pad > blockSize) {
        throw new Error("invalid pkcs7 padding");
    }
    if (pad > buf.length) {
        throw new Error("invalid pkcs7 payload");
    }
    for (let i = 0; i < pad; i += 1) {
        if (buf[buf.length - 1 - i] !== pad) {
            throw new Error("invalid pkcs7 padding");
        }
    }
    return buf.subarray(0, buf.length - pad);
}

/**
 * 解密 WeCom 加密消息
 */
export function decryptWecomEncrypted(params: {
    encodingAESKey: string;
    receiveId?: string;
    encrypt: string;
}): string {
    const aesKey = decodeEncodingAESKey(params.encodingAESKey);
    const iv = aesKey.subarray(0, 16);
    const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
    decipher.setAutoPadding(false);
    const decryptedPadded = Buffer.concat([
        decipher.update(Buffer.from(params.encrypt, "base64")),
        decipher.final(),
    ]);
    const decrypted = pkcs7Unpad(decryptedPadded, CRYPTO.PKCS7_BLOCK_SIZE);

    if (decrypted.length < 20) {
        throw new Error(`invalid payload (expected >=20 bytes, got ${decrypted.length})`);
    }

    // 16 bytes random + 4 bytes length + msg + receiveId
    const msgLen = decrypted.readUInt32BE(16);
    const msgStart = 20;
    const msgEnd = msgStart + msgLen;
    if (msgEnd > decrypted.length) {
        throw new Error(`invalid msg length (msgEnd=${msgEnd}, total=${decrypted.length})`);
    }
    const msg = decrypted.subarray(msgStart, msgEnd).toString("utf8");

    const receiveId = params.receiveId ?? "";
    if (receiveId) {
        const trailing = decrypted.subarray(msgEnd).toString("utf8");
        if (trailing !== receiveId) {
            throw new Error(`receiveId mismatch (expected "${receiveId}", got "${trailing}")`);
        }
    }

    return msg;
}

/**
 * 加密明文为 WeCom 格式
 */
export function encryptWecomPlaintext(params: {
    encodingAESKey: string;
    receiveId?: string;
    plaintext: string;
}): string {
    const aesKey = decodeEncodingAESKey(params.encodingAESKey);
    const iv = aesKey.subarray(0, 16);
    const random16 = crypto.randomBytes(16);
    const msg = Buffer.from(params.plaintext ?? "", "utf8");
    const msgLen = Buffer.alloc(4);
    msgLen.writeUInt32BE(msg.length, 0);
    const receiveId = Buffer.from(params.receiveId ?? "", "utf8");

    const raw = Buffer.concat([random16, msgLen, msg, receiveId]);
    const padded = pkcs7Pad(raw, CRYPTO.PKCS7_BLOCK_SIZE);
    const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
    cipher.setAutoPadding(false);
    const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
    return encrypted.toString("base64");
}