QQ机器人webhook签名验签Java版

QQ机器人WebHook签名验签JAVA版

官方文档中仅提供了GO,PYTHON和NODE的SDK,并且提供的示例也仅有GO版本的,特此为JAVA做一版。

前期准备

QQ开放平台已创建机器人获取对应的机器人ID和密钥等资料信息。

maven引用:

<dependency>
	<groupId>org.bouncycastle</groupId>
	<artifactId>bcprov-jdk15on</artifactId>
	<version>1.70</version>
</dependency>

签名工具类

签名类:

import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters;
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters;
import org.bouncycastle.crypto.signers.Ed25519Signer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

/**
 * @author chen
 * @version 1.0
 * @description: TODO 回调签名加解密帮助类
 * @date 14 4月 2025 14:14
 */

@Slf4j
public class CallBackSignUtil {

    private static final int ED25519_SEED_SIZE = 32;

    /** 
     * @description: TODO 验证签名是否对应 
     * @author chen
     * @date: 15 4月 2025 14:03
     */ 
    public static boolean verifySignature(String appSecret, String xSignatureEd25519, String xSignatureTimestamp, String reqBody) throws IOException {
        byte[] seed = expandSeed(appSecret.getBytes(StandardCharsets.UTF_8));

        // 用 seed 构造 Ed25519 私钥
        Ed25519PrivateKeyParameters privateKey = new Ed25519PrivateKeyParameters(seed, 0);

        // 从私钥推导出公钥
        Ed25519PublicKeyParameters publicKey = privateKey.generatePublicKey();
        byte[] signature = hexStringToByteArray(xSignatureEd25519);

        if (signature.length != 64 || (signature[63] & 0xE0) != 0) {
            return false;
        }

        ByteArrayOutputStream msg = new ByteArrayOutputStream();
        msg.write(xSignatureTimestamp.getBytes());
        msg.write(reqBody.getBytes());
        byte[] msgBytes = msg.toByteArray();

        Ed25519Signer signer = new Ed25519Signer();
        signer.init(false, publicKey);
        signer.update(msgBytes, 0, msgBytes.length);
        return signer.verifySignature(signature);
    }

    /**
     * @description: TODO  生成秘钥
     * @author chen
     * @date: 15 4月 2025 13:50
     */
    public static String generateResponse(String botSecret, String eventTs, String plainToken) throws Exception {
  
        byte[] seed = expandSeed(botSecret.getBytes(StandardCharsets.UTF_8));
        Ed25519PrivateKeyParameters privateKey = new Ed25519PrivateKeyParameters(seed, 0);

        // 生成Ed25519密钥对
        ByteArrayOutputStream msg = new ByteArrayOutputStream();
        msg.write(eventTs.getBytes());
        msg.write(plainToken.getBytes());
        byte[] msgBytes = msg.toByteArray();

        Ed25519Signer signer = new Ed25519Signer();
        signer.init(true, privateKey);
        signer.update(msgBytes, 0, msgBytes.length);
        byte[] signature = signer.generateSignature();

        return bytesToHex(signature);
    }

    /** 
     * @description: TODO 字节转换
     * @author chen
     * @date: 15 4月 2025 13:49
     */ 
    private static String bytesToHex(byte[] bytes) {
        if (bytes == null) {
            throw new IllegalArgumentException("bytes cannot be null");
        }
        StringBuilder result = new StringBuilder();
        for (byte b : bytes) {
            result.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
        }
        return result.toString();
    }

    /**
     * @description: TODO 秘钥补齐
     * @author chen
     * @date: 15 4月 2025 13:50
     */
    private static byte[] expandSeed(byte[] input) {
        if (input == null) {
            throw new IllegalArgumentException("Input cannot be null");
        }
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        while (output.size() < ED25519_SEED_SIZE) {
            output.writeBytes(input);
        }
        return Arrays.copyOf(output.toByteArray(), ED25519_SEED_SIZE);
    }

    /** 
     * @description: TODO 哈希16字符串转字节 
     * @author chen
     * @date: 18 4月 2025 11:04
     */ 
    private static byte[] hexStringToByteArray(String s) {
        int len = s.length();
        if ((len & 1) != 0) {
            throw new IllegalArgumentException("Hex string must have even length");
        }
        byte[] data = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            data[i / 2] = (byte) (
                    (Character.digit(s.charAt(i), 16) << 4)
                            + Character.digit(s.charAt(i + 1), 16)
            );
        }
        return data;
    }
}

调用

@RequestMapping("/test")
    public Mono<Object> testCallBack(
            @RequestBody String reqBody,
            @RequestHeader("X-Signature-Ed25519") String xSignatureEd25519,
            @RequestHeader("X-Signature-Timestamp") String xSignatureTimestamp,
            ServerWebExchange serverWebExchange
            ) throws Exception {

        log.info("xSignatureEd25519:{};xSignatureTimestamp:{};reqBody:{}", xSignatureEd25519, xSignatureTimestamp, reqBody);

        if (!CallBackSignUtil.verifySignature(
                botConfig.getAppSecret(),
                xSignatureEd25519,
                xSignatureTimestamp,
                reqBody
        )) {
            throw new RuntimeException("签名验证失败");
        }

        // 处理具体的调用 请根据API文档做具体的处理
        // 由于回调并不需要
        /*
        ParentCallBackDto parentCallBackDto = JSONObject.parseObject(reqBody, ParentCallBackDto.class);
        executor.execute(() -> {
            try {
                Object result = callbackApiConfig.execute(parentCallBackDto);
            } catch ( Exception e ) {
                log.error("执行回调接口失败", e);
            }
        });
        // 仅处理op类型为13的具体的处理类是下面的
        <!--  public Object execute(AllCallBackDto request) throws Exception {
            CallBackTestDto callBackTestDto = (CallBackTestDto) request;

            log.info("callBackTestDto:{}", callBackTestDto);
            JSONObject jsonResponse = new JSONObject();
            jsonResponse.put("plain_token", callBackTestDto.getPlainToken());
            jsonResponse.put(
                    "signature",
                    CallBackSignUtil.generateResponse(
                            botConfig.getAppSecret(),
                            callBackTestDto.getEventTs(),
                            callBackTestDto.getPlainToken()
                    )
            );
            return jsonResponse;
        }    -->
        */

        serverWebExchange.getResponse().getHeaders().add("X-Bot-Appid", botConfig.getAppId());
        serverWebExchange.getResponse().getHeaders().add("User-Agent", "QQBot-Callback");

        return Mono.just("成功接收");
    }
posted @ 2025-04-18 11:17  不偷数据的输入法  阅读(241)  评论(0)    收藏  举报