HMAC-MD5签名的Java实现

协议标准

中电联的协议《T/CEC 102.4—2016 电动汽车充换电服务信息交换 第4部分:数据传输及安全》中描述了一个名为HMAC-MD5的签名方法,具体如下图:

在这里插入图片描述
用于数据传输过程的报文签名。本文是记录如何实现这个签名。

(顺便吐槽一下这个协议中算法的文字描述部分非常不严谨,让人很疑惑。描述中总是用字符串代替字节数组,让我以为要把数据转为字符呢。结果根本不是。最后是看着公式实现的。)

准备内容

MD5工具类Md5Utils

根据协议描述,我们需要Md5的实现。我们这里直接使用之前的工具类,不再这里讨论。

这个工具类类图如下:
Md5Utils类图
代码:

package net.wangds.utils;

import java.io.UnsupportedEncodingException;
import java.math.BigInteger;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Security;

/**
 * md5工具.
 */
public class Md5Utils {

    /**
     * 字符串的md5.
     * @param plainText 输入字符串.
     * @return md5(utf8编码).
     */
    public static String md5(String plainText) {
        return md5(plainText, StandardCharsets.UTF_8);
    }

    /**
     * 字符串的md5.
     * @param plainText 输入字符串.
     * @param encode 字符集.
     * @return md5.
     * @see #md5(String, Charset)
     */
    @Deprecated
    public static String md5(String plainText, String encode) {
        try {
            return md5(plainText.getBytes(encode));
        } catch (UnsupportedEncodingException e) {
            return "";
        }
    }

    /**
     * 字符串的md5.
     * @param plainText 输入字符串.
     * @param charset 字符集.
     * @return md5.
     */
    public static String md5(String plainText, Charset charset) {
        return md5(plainText.getBytes(charset));
    }

    /**
     * 字符串的md5.
     * @param plainText 输入字符串.
     * @return md5(utf8编码).
     */
    public static String md5_16(String plainText) {
        return md5_16(plainText, StandardCharsets.UTF_8);
    }

    /**
     * 字符串的md5.
     * @param plainText 输入字符串.
     * @param encode 字符集.
     * @return md5.
     * @see #md5(String, Charset)
     */
    @Deprecated
    public static String md5_16(String plainText, String encode) {
        try {
            return md5(plainText.getBytes(encode)).substring(8,24);
        } catch (UnsupportedEncodingException e) {
            return "";
        }
    }

    /**
     * 字符串的16位md5.
     * @param plainText 输入字符串.
     * @param charset 字符集.
     * @return md5.
     */
    public static String md5_16(String plainText, Charset charset){
        return md5(plainText, charset).substring(8,24);
    }

    /**
     * 数据的md5.
     * @param data 输入字符串.
     * @return md5.
     */
    public static byte[] md5Bytes(byte[] data) {
        try {
            // 生成一个MD5加密计算摘要
            MessageDigest md = MessageDigest.getInstance("MD5");
            //对字符串进行加密
            md.update(data);
            //获得加密后的数据
            return md.digest();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("没有md5这个算法");
        }
    }

    /**
     * 数据的md5.
     * @param data 输入字符串.
     * @return md5.
     */
    public static String md5(byte[] data) {
        return HexUtils.bytes2HexString(md5Bytes(data));
    }


}

Hex编码工具类HexUtils

同时,测试等工作也需要一个Hex编码工具,类图如下:
HexUtils的类图

HexUtils的代码如下:

package net.wangds.utils;

@SuppressWarnings("unused")
public class HexUtils {

    /**
     * bytes2HexString.
     * 字节数组转16进制字符串.
     * @param bs 字节数组.
     * @return 16进制字符串.
     */
    public static String bytes2HexString(byte[] bs) {
        StringBuilder result = new StringBuilder();
        for (byte b1 : bs) {
            result.append(String.format("%02X", b1));
        }
        return result.toString();
    }

    /**
     * 字节数组转大写16进制字符串.
     * @param bs 字节数组.
     * @return 16进制字符串.
     */
    public static String bytes2UpperCaseHexString(byte[] bs) {
        return bytes2HexString(bs).toUpperCase();
    }

    /**
     * 字节数组转小写16进制字符串.
     * @param bs 字节数组.
     * @return 16进制字符串.
     */
    public static String bytes2LowerCaseHexString(byte[] bs) {
        return bytes2HexString(bs).toLowerCase();
    }


    /**
     * bytes2HexString.
     * 字节数组转16进制字符串.
     * @param bs 字节数组.
     * @return 16进制字符串.
     */
    public static String bytes2HexString(byte[] bs, int offset, int length) {
        StringBuilder result = new StringBuilder();
        for (int i=0; i<length; i++) {
            byte b1 = bs[offset+i];
            result.append(String.format("%02X", b1));
        }
        return result.toString();
    }

    /**
     * hexString2Bytes.
     * 16进制字符串转字节数组.
     * @param src 16进制字符串.
     * @return 字节数组.
     * 
     */
    public static byte[] hexString2Bytes(String src) {
        int l = src.length() / 2;
        byte[] ret = new byte[l];
        for (int i = 0; i < l; i++) {
            ret[i] = Integer.valueOf(src.substring(i * 2, i * 2 + 2), 16).byteValue();
        }
        return ret;
    }


    /**
     * string2HexUTF8.
     * 字符UTF8串转16进制字符串.
     * @param strPart 字符串.
     * @return 16进制字符串.
     * 
     */
    public static String string2HexUTF8(String strPart) {

        return string2HexString(strPart,"UTF-8");
    }

    /**
     * string2HexUTF8.
     * 字符UTF-16LE串转16进制字符串,此UTF-16LE等同于C#中的Unicode
     * @param strPart 字符串.
     * @return 16进制字符串.
     * 
     */
    public static String string2HexUTF16LE(String strPart) {

        return string2HexString(strPart,"UTF-16LE");
    }

    /**
     * string2HexUnicode.
     * 字符Unicode串转16进制字符串.
     * @param strPart 字符串.
     * @return 16进制字符串.
     * 
     */
    public static String string2HexUnicode(String strPart) {

        return string2HexString(strPart,"Unicode");
    }
    /**
     * string2HexGBK.
     * 字符GBK串转16进制字符串.
     * @param strPart 字符串.
     * @return 16进制字符串.
     * 
     */
    public static String string2HexGBK(String strPart) {

        return string2HexString(strPart,"GBK");
    }

    /**
     * string2HexString.
     * 字符串转16进制字符串.
     * @param strPart 字符串.
     * @param tochartype hex目标编码.
     * @return 16进制字符串.
     * 
     */
    public static String string2HexString(String strPart,String tochartype) {
        try{
            return bytes2HexString(strPart.getBytes(tochartype));
        }catch (Exception e){
            return "";
        }
    }

    /**
     * hexUTF82String.
     * 16进制UTF-8字符串转字符串.
     * @param src 16进制字符串.
     * @return 字节数组.
     * 
     */
    public static String hexUTF82String(String src) {

        return hexString2String(src,"UTF-8","UTF-8");
    }

    /**
     * hexUTF16LE2String.
     * 16进制UTF-8字符串转字符串,,此UTF-16LE等同于C#中的Unicode.
     * @param src 16进制字符串.
     * @return 字节数组.
     * 
     */
    public static String hexUTF16LE2String(String src) {

        return hexString2String(src,"UTF-16LE","UTF-8");
    }

    /**
     * hexGBK2String.
     * 16进制GBK字符串转字符串.
     * @param src 16进制字符串.
     * @return 字节数组.
     * 
     */
    public static String hexGBK2String(String src) {

        return hexString2String(src,"GBK","UTF-8");
    }

    /**
     * hexUnicode2String.
     * 16进制Unicode字符串转字符串.
     * @param src 16进制字符串.
     * @return 字节数组.
     * 
     */
    public static String hexUnicode2String(String src) {
        return hexString2String(src,"Unicode","UTF-8");
    }

    /**
     * hexString2String 16进制字符串转字符串.
     * @param src 16进制字符串.
     * @return 字节数组.
     * 
     */
    public static String hexString2String(String src,String oldchartype, String chartype) {
        byte[] bts=hexString2Bytes(src);
        try{if(oldchartype.equals(chartype))
            return new String(bts,oldchartype);
        else
            return new String(new String(bts,oldchartype).getBytes(),chartype);
        }
        catch (Exception e){

            return"";
        }
    }


}

公共部分

AbstractHMacMd5Utils类

  • 协议中的报文组装过程

从协议应用场景上看,用于充电桩的信息交换,而且示例中,直接给了运营商Id,信息,时间戳,序列号等字段内容。

这些字段如何使用并没有在描述HMAC-MD5的附录C中说明,而是在协议正文。这部分内容如下图:
参数签名规范

  • 报文组装过程的实现

我们在这里用AbstractHMacMd5Utils类封装这个组装报文的功能。类图如下:

AbstractHMacMd5Utils assembleData(opId:String, data:String, ts:String, seq:String,charset:Charset)

这个类是后续开发的工具类的基类,在用工具类中可以方便地调用组装报文的方法assembleData

类代码如下:

package net.wangds.cnpg.cpmn.utils;

import java.nio.charset.Charset;

/**
 * .
 * <p></p>
 *
 * @author eastone 2020/10/21 11:45.
 */
public class AbstractHMacMd5Utils {

    public static byte[] assembleData(String operatorID, String data, String timestamp, String seq, Charset charset){
        return String.format("%s%s%s%s", operatorID,data, timestamp, seq).getBytes(charset);
    }

}

第一次实现-简单的HMacMd5Utils类

算法中的掩码

根据算法定义,需要有两个用于异或操作的掩码opad和ipad。

AbstractHMacMd5Utils HMacMd5Utils <static> <final> - opad : byte = 0x5c; <static> <final> - ipad : byte = 0x36; <static> <final> - keyLen: byte = 64;

同时定义了一个常量keyLen用来代表密钥长度。

密钥处理

算法中的密钥,长度为64字节,也就是64*8=512 bit。

根据算法要求,密钥长度小于64位的时候,要补"\0"。协议描述给的示例密钥是"1234567890abcdef",所以最开始补了字符"0",结果不对,要用"\0"才可以。

另外,当密钥字符串长度大于64字节的时候,我们这里选择用密钥的md5散列码作为真实密钥。

关于补0的问题,我们这里没有使用补零的过程,因为Java中byte默认是0。密钥的处理过程用到了ByteBuffer,创建固定大小的ByteBuffer对象,从起始位置写入密钥,未写入的部分自然是"\0".

Created with Raphaël 2.2.0 开始 初始化buf:ByteBuffer变量。 参数密钥长度是否大于64字节? 向buf中写入密钥的md5散列码 结束:返回buf中的数组 将密钥写入buf对象 yes no

实现后,类图如下:

AbstractHMacMd5Utils HMacMd5Utils <static> <final> - opad : byte = 0x5c; <static> <final> - ipad : byte = 0x36; <static> <final> - keyLen: byte = 64; static> - generateKey(key:String)

代码:

    /**
     * 处理密钥.
     * <p>密钥补为64字节;长于64字节的用md5散列结果.</p>
     * @param key 签名密钥.
     * @return 结果.
     */
    private static byte[] generateKey(String key) {

        ByteBuffer buf = ByteBuffer.allocate(keyLen);
        if(key.length()>keyLen){
            buf.put(Md5Utils.md5Bytes(key.getBytes(StandardCharsets.UTF_8))) ;
        }else{
            buf.put(key.getBytes());
        }

        return buf.array();

    }

"istr“和”ostr"的生成

istr和ostr是密钥数组分别和ipad和opad常数做异或得到的结果。类图如下:

AbstractHMacMd5Utils HMacMd5Utils <static> <final> - opad : byte = 0x5c; <static> <final> - ipad : byte = 0x36; <static> <final> - keyLen: byte = 64; static> - generateKey(key:String) static> - generateIstr(key: byte[]) static> - generateOstr(key: byte[])
  • generateIstr(key:byte[]): byte[]的实现如下:
    /**
     * 生成istr数据.
     * @param key 签名密钥.
     *            <p>64字节长.</p>
     * @param out 输出数组.
     * @return 结果.
     */
    public static byte[] generateIstr(byte[] key, byte[] out){
        for(int i=0;i<keyLen;i++){
            out[i]=(byte)(key[i]^ipad);
        }
        return out;
    }
  • generateOstr(key:byte[]): byte[]的实现如下:
 /**
     * 生成ostr数据.
     * @param key 签名密钥.
     *            <p>64字节长.</p>
     * @param out 输出数组.
     * @return 结果.
     */
    private static byte[] generateOstr(byte[] key, byte[] out) {
        for(int i=0;i<keyLen;i++){
            out[i]=(byte)(key[i]^opad);
        }
        return out;
    }`

签名的实现

在以上内容实现之后,签名过程的开发就相对简单了,流程如下:

Created with Raphaël 2.2.0 开始: 参数key为密钥文本;参数data为要加密的数据 <<参数>> key:String // 密钥文本 <<参数>> data: byte[] // 签名数据 调用generateKey(密钥文本)方法,生成密钥数据k 变量k:密钥:byte[] 变量data1:byte[] - 第一次md5计算的输入数据 计算istr,并将istr写入data1中 将参数data继续写入data1中 计算data1的md5值,并保存在变量md5One中 变量md5One:byte[] //第一次md5计算结果 变量data2 //第二次md5计算的输入数据 计算ostr并写入data2中 计算第二次md5结果res 返回值 res:byte[] 第二次md5计算的结果 结束

代码如下:

    /**
     * 数据签名.
     * @param key 签名密钥.
     * @param data 数据.
     * @return 签名.
     */
    public static byte[] sign(String key, byte[] data){

        byte[] k = generateKey(key);
        byte[] str = new byte[keyLen];

        byte[] data1 = new byte[keyLen+data.length];
        System.arraycopy(generateIstr(k, str), 0, data1, 0, keyLen);
        System.arraycopy(data, 0, data1, keyLen, data.length);
        byte[] md5One = Md5Utils.md5Bytes(data1);

        byte[] data2 = new byte[keyLen+md5One.length];
        System.arraycopy(generateOstr(k, str), 0, data2, 0, keyLen);
        System.arraycopy(md5One, 0, data2, keyLen, md5One.length);

        return Md5Utils.md5Bytes(data2);
    }

这里特别的地方是str这个变量,用来保存istr和ostr。因为这两个变量不是同时使用的,所以用了同一块内存来保存,不用分配两个字节数组。虽然对于java来讲这么操作不会有什么影响,不过还是尽量节约内存,养成良好习惯。

为了调用方便,我们增加两个常用接口:


    /**
     * 生成签名字符串.
     * @param key 签名密钥.
     *            <p>e.g.: 1234567890abcdef</p>
     * @param operatorID 运营商Id.
     *                   <p>e.g.: 123456789</p>
     * @param data 数据.
     *             <p>e.g.: il7B0BSEjFdzpyKzfOFpvg/Se1CP802RItKYFPfSLRxJ3jf0bVl9hvYOEktPAYW2nd7S8MBcyHYyacHKbISq5iTmDzG+ivnR+SZJv3USNTYVMz9rCQVSxd0cLlqsJauko79NnwQJbzDTyLooYoIwz75qBOH2/xOMirpeEqRJrF/EQjWekJmGk9RtboXePu2rka+Xm51syBPhiXJAq0GfbfaFu9tNqs/e2Vjja/ltE1M0lqvxfXQ6da6HrThsm5id4ClZFIi0acRfrsPLRixS/IQYtksxghvJwbqOsbIsITail9Ayy4tKcogeEZiOO+4Ed264NSKmk7l3wKwJLAFjCFogBx8GE3OBz4pqcAn/ydA=</p>
     * @param timestamp 时间戳.
     *                  <p>e.g.: 20160729142400</p>
     * @param seq 序列.
     *            <p>e.g.: 0001</p>
     * @return 签名.
     * <p>Hex格式, e.g.: 745166E8C43C84D37FFEC0F529C4136F</p>
     */
    public static String signHex(String key, String operatorID, String data, String timestamp, String seq){
        return HexUtils.bytes2HexString(sign(key, operatorID, data, timestamp, seq));
    }

    /**
     * 生成签名.
     * @param key 签名密钥.
     *            <p>e.g.: 1234567890abcdef</p>
     * @param operatorID 运营商Id.
     *                   <p>e.g.: 123456789</p>
     * @param data 数据.
     *             <p>e.g.: il7B0BSEjFdzpyKzfOFpvg/Se1CP802RItKYFPfSLRxJ3jf0bVl9hvYOEktPAYW2nd7S8MBcyHYyacHKbISq5iTmDzG+ivnR+SZJv3USNTYVMz9rCQVSxd0cLlqsJauko79NnwQJbzDTyLooYoIwz75qBOH2/xOMirpeEqRJrF/EQjWekJmGk9RtboXePu2rka+Xm51syBPhiXJAq0GfbfaFu9tNqs/e2Vjja/ltE1M0lqvxfXQ6da6HrThsm5id4ClZFIi0acRfrsPLRixS/IQYtksxghvJwbqOsbIsITail9Ayy4tKcogeEZiOO+4Ed264NSKmk7l3wKwJLAFjCFogBx8GE3OBz4pqcAn/ydA=</p>
     * @param timestamp 时间戳.
     *                  <p>e.g.: 20160729142400</p>
     * @param seq 序列.
     *            <p>e.g.: 0001</p>
     * @return 签名.
     * <p>e.g.: 745166E8C43C84D37FFEC0F529C4136F(Hex格式)</p>
     */
    public static byte[] sign(String key, String operatorID, String data, String timestamp, String seq){
        return sign(key, assembleData(operatorID,data, timestamp, seq, StandardCharsets.UTF_8));
    }

类图如下:

AbstractHMacMd5Utils HMacMd5Utils <static> <final> - opad : byte = 0x5c; <static> <final> - ipad : byte = 0x36; <static> <final> - keyLen: byte = 64; static> - generateKey(key:String) static> - generateIstr(key: byte[]) static> - generateOstr(key: byte[]) static> + sign(key: String, data: byte[]) static> + sign(key: String, operatorId:String, data:String, ts:String, seq:String) static> + signHex(key: String, operatorId:String, data:String, ts:String, seq:String)

测试过程

测试过程用JUnit实现,代码如下:

    String want = "745166E8C43C84D37FFEC0F529C4136F";
    String key = "1234567890abcdef";
    String opId = "123456789";
    String data = "il7B0BSEjFdzpyKzfOFpvg/Se1CP802RItKYFPfSLRxJ3jf0bVl9hvYOEktPAYW2nd7S8MBcyHYyacHKbISq5iTmDzG+ivnR+SZJv3USNTYVMz9rCQVSxd0cLlqsJauko79NnwQJbzDTyLooYoIwz75qBOH2/xOMirpeEqRJrF/EQjWekJmGk9RtboXePu2rka+Xm51syBPhiXJAq0GfbfaFu9tNqs/e2Vjja/ltE1M0lqvxfXQ6da6HrThsm5id4ClZFIi0acRfrsPLRixS/IQYtksxghvJwbqOsbIsITail9Ayy4tKcogeEZiOO+4Ed264NSKmk7l3wKwJLAFjCFogBx8GE3OBz4pqcAn/ydA=";
    String ts = "20160729142400";
    String seq = "0001";

    @Test
    public void test(){
        TestCase.assertEquals(
                want, HMacMd5Utils.signHex(key, opId, data, ts, seq)
        );
    }

尚未实现的功能

HMAC-MD5作为一个签名算法,当然应该有校验的过程。不过,我们这里暂时不实现校验,因为后面还有其他的工作。如果有需要,可以尝试自己实现校验过程。

JCE接口实现

JCE(Java Cryptography Extension)是Java的加密算法支持,提供了统一的加密、解密、散列、签名、密钥算法。例如,我们MD5工具类就使用了JCE的实现,如下:

   /**
     * 数据的md5.
     * @param data 输入字符串.
     * @return md5.
     */
    public static byte[] md5Bytes(byte[] data) {
        try {
            // 生成一个MD5加密计算摘要
            MessageDigest md = MessageDigest.getInstance("MD5");
            //对字符串进行加密
            md.update(data);
            //获得加密后的数据
            return md.digest();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("没有md5这个算法");
        }
    }

我们的HMAC-MD5现在只能作为一个工具类使用。假如有另外一段代码已经用了JCE实现的其他方式签名,现在要改成HMAC-MD5,那么他需要重写算法。而如果我们能够提供JCE接口的HMAC-MD5算法。那么他就能很容易的切换算法。

密钥

密钥相关的部分分为几个内容:

  • KeySpec: 用来保存密钥
  • PublicKey:加密公钥
  • PrivateKey:解密私钥
  • Key:密钥
  • KeyFactory: 密钥工厂

密钥相关类

  1. 首先HMacMd5KeySpec用于保存密钥数据。
  2. 因为HMAC-MD5算法不区分公钥/私钥,所以HMacMd5Key同时实现PublicKey合PrivateKey接口。
  3. HMacMd5KeyFactory类用于生成HMacMd5相关的密钥。

类HMacMd5KeySpec

本类代码如下:

package net.wangds.tcec.tcec102d4.messagedigest.hmacmd5;

import net.wangds.utils.Md5Utils;

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.spec.KeySpec;

/**
 * .
 * <p></p>
 *
 * @author wangds 2020/10/20 12:31.
 */
public class HMacMd5KeySpec implements KeySpec {

    private static final int LEN = 64;

    ByteBuffer buf = ByteBuffer.allocate(LEN);

    public HMacMd5KeySpec(String key){
        this(key.getBytes(StandardCharsets.ISO_8859_1));
    }

    public HMacMd5KeySpec(byte[] key){
        buf.position(0);
        buf.limit(buf.capacity());

        if(key.length>LEN){
            key = Md5Utils.md5Bytes(key);
        }

        buf.put(key);

        buf.flip();
        buf.rewind();

    }

    public static KeySpec of(String s) {
        return new HMacMd5KeySpec(s);
    }

    public byte[] toBytes(){
        return buf.array();
    }

}

可以看到这个类主要是通过一个字节缓存保存密钥数据。

类HMacMd5Key

这个类用于实现PublicKey、PrivateKey接口,是签名、验证过程中设置密钥时所需的对象类型。


package net.wangds.tcec.tcec102d4.messagedigest.hmacmd5;

import java.security.PrivateKey;
import java.security.PublicKey;

/**
 * .
 * <p></p>
 *
 * @author wangds 2020/10/20 12:45.
 */
public class HMacMd5Key implements PublicKey, PrivateKey {

    private final HMacMd5KeySpec spec;

    public HMacMd5Key(HMacMd5KeySpec spec){
        this.spec = spec;
    }

    @Override
    public String getAlgorithm() {
        return "HMacMd5";
    }

    @Override
    public String getFormat() {
        return "NOPadding";
    }

    @Override
    public byte[] getEncoded() {
        return spec.toBytes();
    }
}


可以看到,类中关联了一个KeySpec的属性用于保存密钥,并指定改了密钥的名称和格式。

类HMacMd5KeyFactory

这里需要特殊说明一下,此类的父类时KeyFactorySpi,而不是KeyFactory。JCE的工厂类会将Spi类包装为Factory类,这个过程不用我们实现,我们实现KeyFacotrySpi就可以了。

代码如下:

package net.wangds.tcec.tcec102d4.messagedigest.hmacmd5;

import org.apache.commons.lang3.StringUtils;

import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;

/**
 * .
 * <p></p>
 *
 * @author wangds 2020/10/20 12:34.
 */
public class HMacMd5KeyFactory extends KeyFactorySpi {


    @Override
    protected PublicKey engineGeneratePublic(KeySpec keySpec) throws InvalidKeySpecException {
        if(keySpec instanceof  HMacMd5KeySpec) {
            return new HMacMd5Key((HMacMd5KeySpec) keySpec);
        }
        throw new InvalidKeySpecException("只支持HMacMd5KeySpec");
    }

    @Override
    protected PrivateKey engineGeneratePrivate(KeySpec keySpec) throws InvalidKeySpecException {
        if(keySpec instanceof  HMacMd5KeySpec) {
            return new HMacMd5Key((HMacMd5KeySpec) keySpec);
        }
        throw new InvalidKeySpecException("只支持HMacMd5KeySpec");
    }

    @Override
    @SuppressWarnings({"unchecked","cast"})
    protected <T extends KeySpec> T engineGetKeySpec(Key key, Class<T> keySpec) throws InvalidKeySpecException {
        if(StringUtils.equals(keySpec.getClass().getName(), HMacMd5KeySpec.class.getName())){
            return (T)new HMacMd5KeySpec(key.getEncoded());
        }
        throw new InvalidKeySpecException("只支持HMacMd5KeySpec");
    }

    @Override
    protected Key engineTranslateKey(Key key) throws InvalidKeyException {
        byte[] keyData = key.getEncoded();
        return new HMacMd5Key(new HMacMd5KeySpec(keyData));
    }
}

通过上面代码,我们可以看到KeyFactorySpi这个接口主要是提供Key的生成和转换功能。我们这个代码本身不用考虑Key的那么多兼容性,毕竟应用场景只是行业内,所以在代码中看到异常情况处理基本是抛错提示。

签名算法实现

签名算法是通过一个SignatureSpi类的子类HMacMd5SignSpi实现的。如下图:

SignSpi的实现
在这个类中,通过engineInitSign()、engineSign()等方法实现了签名过程;通过engineInitVerify()和engineVerify()等方法实现类验证过程。

算法可参考之前Utils类的算法,这里只提供代码。

package net.wangds.tcec.tcec102d4.messagedigest.hmacmd5;

import net.wangds.utils.HexUtils;
import net.wangds.utils.Md5Utils;
import org.apache.commons.lang3.StringUtils;

import java.nio.ByteBuffer;
import java.security.*;

/**
 * HMAC-MD5签名算法实现.
 * <p></p>
 *
 * @author wangds 2020/10/20 12:51.
 */
public class HMacMd5SignSpi extends SignatureSpi {


    private static final byte opad = 0x5c;
    private static final byte ipad = 0x36;
    private static final byte KEN_LEN = 64;

    private Key key;

    private ByteBuffer buf = ByteBuffer.allocate(KEN_LEN);

    @Override
    protected void engineInitVerify(PublicKey publicKey) throws InvalidKeyException {
        this.key = new HMacMd5KeyFactory().engineTranslateKey(publicKey);
    }

    @Override
    protected void engineInitSign(PrivateKey privateKey) throws InvalidKeyException {
        this.key = new HMacMd5KeyFactory().engineTranslateKey(privateKey);
    }

    @Override
    protected void engineUpdate(byte b) {
        buf.rewind();
        buf.put(b);
        buf.flip();
    }

    @Override
    protected void engineUpdate(byte[] b, int off, int len) {
        if(buf.capacity()<len){
            buf = ByteBuffer.allocate(len);
        }
        buf.rewind();
        buf.put(b, off, len);
        buf.flip();
    }

    @Override
    protected byte[] engineSign() {
        return calcSigin();
    }

    private byte[] calcSigin() {

        byte[] data = new byte[buf.limit()];
        byte[] k = key.getEncoded();
        int keyLen = k.length;

        byte[] str = new byte[keyLen];

        buf.rewind();
        buf.get(data);


        byte[] data1 = new byte[keyLen+ data.length];
        System.arraycopy(generateIstr(k, str), 0, data1, 0, keyLen);
        System.arraycopy(data, 0, data1, keyLen, data.length);
        byte[] md5One = Md5Utils.md5Bytes(data1);

        byte[] data2 = new byte[keyLen+md5One.length];
        System.arraycopy(generateOstr(k, str), 0, data2, 0, keyLen);
        System.arraycopy(md5One, 0, data2, keyLen, md5One.length);

        return Md5Utils.md5Bytes(data2);
    }

    @Override
    protected boolean engineVerify(byte[] inSign) {

        byte[] calcSign = this.calcSigin();

        String hexCalcSign = HexUtils.bytes2HexString(calcSign);
        String hexInSign = HexUtils.bytes2HexString(inSign);

        return StringUtils.equals(hexCalcSign, hexInSign);
    }

    @Override
    protected void engineSetParameter(String param, Object value) throws InvalidParameterException {
        //skip
    }

    @Override
    protected Object engineGetParameter(String param) throws InvalidParameterException {
        return null;
    }


    /**
     * 生成istr数据.
     * @param key 签名密钥.
     *            <p>64字节长.</p>
     * @param out 输出数组.
     * @return 结果.
     */
    public static byte[] generateIstr(byte[] key, byte[] out){

        for(int i = 0; i< KEN_LEN; i++){
            out[i]=(byte)(key[i]^ipad);
        }
        return out;
    }

    /**
     * 生成ostr数据.
     * @param key 签名密钥.
     *            <p>64字节长.</p>
     * @param out 输出数组.
     * @return 结果.
     */
    private static byte[] generateOstr(byte[] key, byte[] out) {
        for(int i = 0; i< KEN_LEN; i++){
            out[i]=(byte)(key[i]^opad);
        }
        return out;
    }
}

Provider类

Provider类是算法注册的接口。通过Provider,将算法注册到JCE中。

如下:

package net.wangds.tcec.tcec102d4.messagedigest.hmacmd5;

import java.security.Provider;
import java.security.Security;

/**
 * .
 * <p></p>
 *
 * @author wangds 2020/10/21 10:52.
 */
public final class HMacMd5Provider extends Provider {

    static {
        Security.addProvider(new HMacMd5Provider());
    }

    /**
     * Constructs a provider with the specified name, version number,
     * and information.
     *
     */
    protected HMacMd5Provider() {
        super("HMacMd5", 1.0f, "HMAC-MD5 Provider v1.0");
        put("KeyFactory.HMacMd5", "net.wangds.tcec.tcec102d4.messagedigest.hmacmd5.HMacMd5KeyFactory");
        put("Signature.HMacMd5", "net.wangds.tcec.tcec102d4.messagedigest.hmacmd5.HMacMd5SignSpi");
    }
}

Provider类在使用时有两种方式,一种是通过修改JRE配置文件实现的;另外一种就是和JDBC中数据库驱动类似的方式,通过反射接口东财注入的。

动态注入的代码片段如下:

    static {
        try {
            Class.forName("net.wangds.tcec.tcec102d4.messagedigest.hmacmd5.HMacMd5Provider");
        } catch (ClassNotFoundException e) {
            LogHelper.error(e);
        }
    }

JCE接口的测试

代码如下:


    /**
     * 测试直接使用jce接口的方式调用.
     */
    @Test
    public void test1(){



        try {
            Class.forName("net.wangds.tcec.tcec102d4.messagedigest.hmacmd5.HMacMd5Provider");
        } catch (ClassNotFoundException e) {
            LogHelper.error(e);
        }

        try {

            Provider prov = Security.getProvider("HMacMd5");

            LogHelper.dev("prov:"+prov);

            KeyFactory fac = KeyFactory.getInstance("HMacMd5");

            LogHelper.error("fac:"+fac);

            Signature sign = Signature.getInstance("HMacMd5");
            LogHelper.error("sign:"+sign);

            try {
                KeySpec keySpec = HMacMd5KeySpec.of(key);
                PrivateKey pk = fac.generatePrivate(keySpec);

                sign.initSign(pk);
                sign.update(HMacMd5Utils.assembleData(opId, data, ts, seq, StandardCharsets.UTF_8));
                byte[] res = sign.sign();
                byte[] bsSign = new byte[16];
                System.arraycopy(res, res.length-16, bsSign , 0,16);
                LogHelper.dev("sign length:"+res.length);
                String hex = HexUtils.bytes2HexString(bsSign);
                TestCase.assertEquals(want, hex);


                PublicKey pub = fac.generatePublic(keySpec);
                sign.initVerify(pub);
                sign.update(HMacMd5Utils.assembleData(opId, data, ts, seq, StandardCharsets.UTF_8));
                TestCase.assertTrue(sign.verify(res));

            } catch (InvalidKeyException e) {
                e.printStackTrace();
            } catch (InvalidKeySpecException | SignatureException e) {
                e.printStackTrace();
            }


        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }


    }

注意这里也只测试了加密过程。

重新封装工具类

HMacMd5Utils2类

这个接口通过调用JCE风格的实现,便于以后算法的升级和变换。

代码如下:

package net.wangds.cnpg.cpmn.utils;

import net.wangds.log.helper.LogHelper;
import net.wangds.tcec.tcec102d4.messagedigest.hmacmd5.HMacMd5KeySpec;
import net.wangds.utils.HexUtils;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
import java.util.function.BiFunction;

/**
 * .
 * <p></p>
 *
 * @author 王东石 2020/10/21 11:46.
 */
public final class HMacMd5Utils2 extends AbstractHMacMd5Utils {

    static {
        try {
            Class.forName("net.wangds.tcec.tcec102d4.messagedigest.hmacmd5.HMacMd5Provider");
        } catch (ClassNotFoundException e) {
            LogHelper.error(e);
        }
    }

    /**
     * 生成签名.
     * @param key 签名密钥.
     *            <p>e.g.: 1234567890abcdef</p>
     * @param operatorID 运营商Id.
     *                   <p>e.g.: 123456789</p>
     * @param data 数据.
     *             <p>e.g.: il7B0BSEjFdzpyKzfOFpvg/Se1CP802RItKYFPfSLRxJ3jf0bVl9hvYOEktPAYW2nd7S8MBcyHYyacHKbISq5iTmDzG+ivnR+SZJv3USNTYVMz9rCQVSxd0cLlqsJauko79NnwQJbzDTyLooYoIwz75qBOH2/xOMirpeEqRJrF/EQjWekJmGk9RtboXePu2rka+Xm51syBPhiXJAq0GfbfaFu9tNqs/e2Vjja/ltE1M0lqvxfXQ6da6HrThsm5id4ClZFIi0acRfrsPLRixS/IQYtksxghvJwbqOsbIsITail9Ayy4tKcogeEZiOO+4Ed264NSKmk7l3wKwJLAFjCFogBx8GE3OBz4pqcAn/ydA=</p>
     * @param timestamp 时间戳.
     *                  <p>e.g.: 20160729142400</p>
     * @param seq 序列.
     *            <p>e.g.: 0001</p>
     * @param charset 字符集.
     * @return 签名.
     * <p>e.g.: 745166E8C43C84D37FFEC0F529C4136F(Hex格式)</p>
     */
    public static byte[] sign(String key, String operatorID, String data, String timestamp, String seq, Charset charset){
        return sign(key, assembleData(operatorID,data, timestamp, seq, charset));
    }


    /**
     * 生成签名Hex字符串.
     * @param key 签名密钥.
     *            <p>e.g.: 1234567890abcdef</p>
     * @param operatorID 运营商Id.
     *                   <p>e.g.: 123456789</p>
     * @param data 数据.
     *             <p>e.g.: il7B0BSEjFdzpyKzfOFpvg/Se1CP802RItKYFPfSLRxJ3jf0bVl9hvYOEktPAYW2nd7S8MBcyHYyacHKbISq5iTmDzG+ivnR+SZJv3USNTYVMz9rCQVSxd0cLlqsJauko79NnwQJbzDTyLooYoIwz75qBOH2/xOMirpeEqRJrF/EQjWekJmGk9RtboXePu2rka+Xm51syBPhiXJAq0GfbfaFu9tNqs/e2Vjja/ltE1M0lqvxfXQ6da6HrThsm5id4ClZFIi0acRfrsPLRixS/IQYtksxghvJwbqOsbIsITail9Ayy4tKcogeEZiOO+4Ed264NSKmk7l3wKwJLAFjCFogBx8GE3OBz4pqcAn/ydA=</p>
     * @param timestamp 时间戳.
     *                  <p>e.g.: 20160729142400</p>
     * @param seq 序列.
     *            <p>e.g.: 0001</p>
     * @param charset 字符集.
     * @return 签名.
     * <p>根据规范要求,字母大写。e.g.: 745166E8C43C84D37FFEC0F529C4136F</p>
     */
    public static String signHex(String key, String operatorID, String data, String timestamp, String seq, Charset charset){
        return HexUtils.bytes2HexString(sign(key, operatorID, data, timestamp, seq, charset)).toUpperCase();
    }

    /**
     * 生成签名.
     * <p>默认字符集:UTF-8</p>
     * @param key 签名密钥.
     *            <p>e.g.: 1234567890abcdef</p>
     * @param operatorID 运营商Id.
     *                   <p>e.g.: 123456789</p>
     * @param data 数据.
     *             <p>e.g.: il7B0BSEjFdzpyKzfOFpvg/Se1CP802RItKYFPfSLRxJ3jf0bVl9hvYOEktPAYW2nd7S8MBcyHYyacHKbISq5iTmDzG+ivnR+SZJv3USNTYVMz9rCQVSxd0cLlqsJauko79NnwQJbzDTyLooYoIwz75qBOH2/xOMirpeEqRJrF/EQjWekJmGk9RtboXePu2rka+Xm51syBPhiXJAq0GfbfaFu9tNqs/e2Vjja/ltE1M0lqvxfXQ6da6HrThsm5id4ClZFIi0acRfrsPLRixS/IQYtksxghvJwbqOsbIsITail9Ayy4tKcogeEZiOO+4Ed264NSKmk7l3wKwJLAFjCFogBx8GE3OBz4pqcAn/ydA=</p>
     * @param timestamp 时间戳.
     *                  <p>e.g.: 20160729142400</p>
     * @param seq 序列.
     *            <p>e.g.: 0001</p>
     * @return 签名.
     * <p>e.g.: 745166E8C43C84D37FFEC0F529C4136F(Hex格式)</p>
     */
    public static byte[] sign(String key, String operatorID, String data, String timestamp, String seq){
        return sign(key, assembleData(operatorID,data, timestamp, seq, StandardCharsets.UTF_8));
    }

    /**
     * 生成签名Hex字符串.
     * <p>默认字符集:UTF-8</p>
     * @param key 签名密钥.
     *            <p>e.g.: 1234567890abcdef</p>
     * @param operatorID 运营商Id.
     *                   <p>e.g.: 123456789</p>
     * @param data 数据.
     *             <p>e.g.: il7B0BSEjFdzpyKzfOFpvg/Se1CP802RItKYFPfSLRxJ3jf0bVl9hvYOEktPAYW2nd7S8MBcyHYyacHKbISq5iTmDzG+ivnR+SZJv3USNTYVMz9rCQVSxd0cLlqsJauko79NnwQJbzDTyLooYoIwz75qBOH2/xOMirpeEqRJrF/EQjWekJmGk9RtboXePu2rka+Xm51syBPhiXJAq0GfbfaFu9tNqs/e2Vjja/ltE1M0lqvxfXQ6da6HrThsm5id4ClZFIi0acRfrsPLRixS/IQYtksxghvJwbqOsbIsITail9Ayy4tKcogeEZiOO+4Ed264NSKmk7l3wKwJLAFjCFogBx8GE3OBz4pqcAn/ydA=</p>
     * @param timestamp 时间戳.
     *                  <p>e.g.: 20160729142400</p>
     * @param seq 序列.
     *            <p>e.g.: 0001</p>
     * @return 签名.
     * <p>e.g.: 745166E8C43C84D37FFEC0F529C4136F</p>
     */
    public static String signHex(String key, String operatorID, String data, String timestamp, String seq){
        return HexUtils.bytes2HexString(sign(key, operatorID, data, timestamp, seq)).toUpperCase();
    }


    /**
     * 生成签名.
     * @param key 签名密钥.
     * @param data 数据.
     * @return 签名.
     * <p>e.g.: 745166E8C43C84D37FFEC0F529C4136F(Hex格式)</p>
     */
    public static byte[] sign(String key, byte[] data){
        return withSign((fac, sign)->{
            try {
                KeySpec keySpec = HMacMd5KeySpec.of(key);
                PrivateKey pk = fac.generatePrivate(keySpec);

                sign.initSign(pk);
                sign.update(data);
                return sign.sign();
            } catch (InvalidKeyException|InvalidKeySpecException | SignatureException e) {
                throw new RuntimeException(e);
            }
        });
    }

    /**
     * 生成签名Hex字符串.
     * @param key 签名密钥.
     * @param data 数据.
     * @return 签名.
     * <p>e.g.: 745166E8C43C84D37FFEC0F529C4136F</p>
     */
    public static String signHex(String key, byte[] data){
        return HexUtils.bytes2HexString(sign(key, data)).toUpperCase();
    }

    /**
     * 验证Hex格式签名.
     * @param key 签名密钥.
     *            <p>e.g.: 1234567890abcdef</p>
     * @param sign 签名.
     *             <p>e.g.:745166E8C43C84D37FFEC0F529C4136F</p>
     * @param operatorID 运营商Id.
     *                   <p>e.g.: 123456789</p>
     * @param data 数据.
     *             <p>e.g.: il7B0BSEjFdzpyKzfOFpvg/Se1CP802RItKYFPfSLRxJ3jf0bVl9hvYOEktPAYW2nd7S8MBcyHYyacHKbISq5iTmDzG+ivnR+SZJv3USNTYVMz9rCQVSxd0cLlqsJauko79NnwQJbzDTyLooYoIwz75qBOH2/xOMirpeEqRJrF/EQjWekJmGk9RtboXePu2rka+Xm51syBPhiXJAq0GfbfaFu9tNqs/e2Vjja/ltE1M0lqvxfXQ6da6HrThsm5id4ClZFIi0acRfrsPLRixS/IQYtksxghvJwbqOsbIsITail9Ayy4tKcogeEZiOO+4Ed264NSKmk7l3wKwJLAFjCFogBx8GE3OBz4pqcAn/ydA=</p>
     * @param timestamp 时间戳.
     *                  <p>e.g.: 20160729142400</p>
     * @param seq 序列.
     *            <p>e.g.: 0001</p>
     * @return 验证结果.
     */
    public static boolean verifyHex(String key, String sign, String operatorID, String data, String timestamp, String seq){
        return verify(key, HexUtils.hexString2Bytes(sign), operatorID, data, timestamp, seq);
    }

    /**
     * 验证签名.
     * @param key 签名密钥.
     *            <p>e.g.: 1234567890abcdef</p>
     * @param sign 签名.
     *             <p>e.g.:745166E8C43C84D37FFEC0F529C4136F(Hex格式)</p>
     * @param operatorID 运营商Id.
     *                   <p>e.g.: 123456789</p>
     * @param data 数据.
     *             <p>e.g.: il7B0BSEjFdzpyKzfOFpvg/Se1CP802RItKYFPfSLRxJ3jf0bVl9hvYOEktPAYW2nd7S8MBcyHYyacHKbISq5iTmDzG+ivnR+SZJv3USNTYVMz9rCQVSxd0cLlqsJauko79NnwQJbzDTyLooYoIwz75qBOH2/xOMirpeEqRJrF/EQjWekJmGk9RtboXePu2rka+Xm51syBPhiXJAq0GfbfaFu9tNqs/e2Vjja/ltE1M0lqvxfXQ6da6HrThsm5id4ClZFIi0acRfrsPLRixS/IQYtksxghvJwbqOsbIsITail9Ayy4tKcogeEZiOO+4Ed264NSKmk7l3wKwJLAFjCFogBx8GE3OBz4pqcAn/ydA=</p>
     * @param timestamp 时间戳.
     *                  <p>e.g.: 20160729142400</p>
     * @param seq 序列.
     *            <p>e.g.: 0001</p>
     * @return 验证结果.
     */
    public static boolean verify(String key, byte[] sign, String operatorID, String data, String timestamp, String seq){
        return verify(key, sign, assembleData(operatorID,data, timestamp, seq, StandardCharsets.UTF_8));
    }

    /**
     * 验证Hex格式签名.
     * @param key 签名密钥.
     *            <p>e.g.: 1234567890abcdef</p>
     * @param sign 签名.
     *             <p>e.g.:745166E8C43C84D37FFEC0F529C4136F(Hex格式)</p>
     * @param operatorID 运营商Id.
     *                   <p>e.g.: 123456789</p>
     * @param data 数据.
     *             <p>e.g.: il7B0BSEjFdzpyKzfOFpvg/Se1CP802RItKYFPfSLRxJ3jf0bVl9hvYOEktPAYW2nd7S8MBcyHYyacHKbISq5iTmDzG+ivnR+SZJv3USNTYVMz9rCQVSxd0cLlqsJauko79NnwQJbzDTyLooYoIwz75qBOH2/xOMirpeEqRJrF/EQjWekJmGk9RtboXePu2rka+Xm51syBPhiXJAq0GfbfaFu9tNqs/e2Vjja/ltE1M0lqvxfXQ6da6HrThsm5id4ClZFIi0acRfrsPLRixS/IQYtksxghvJwbqOsbIsITail9Ayy4tKcogeEZiOO+4Ed264NSKmk7l3wKwJLAFjCFogBx8GE3OBz4pqcAn/ydA=</p>
     * @param timestamp 时间戳.
     *                  <p>e.g.: 20160729142400</p>
     * @param seq 序列.
     *            <p>e.g.: 0001</p>
     * @param charset 字符集.
     *
     * @return 验证结果.
     */
    public static boolean verifyHex(String key, String sign, String operatorID, String data, String timestamp, String seq, Charset charset){
        return verify(key, HexUtils.hexString2Bytes(sign), operatorID,data, timestamp, seq, charset);
    }

    /**
     * 验证签名.
     * @param key 签名密钥.
     *            <p>e.g.: 1234567890abcdef</p>
     * @param sign 签名.
     *             <p>e.g.:745166E8C43C84D37FFEC0F529C4136F(Hex格式)</p>
     * @param operatorID 运营商Id.
     *                   <p>e.g.: 123456789</p>
     * @param data 数据.
     *             <p>e.g.: il7B0BSEjFdzpyKzfOFpvg/Se1CP802RItKYFPfSLRxJ3jf0bVl9hvYOEktPAYW2nd7S8MBcyHYyacHKbISq5iTmDzG+ivnR+SZJv3USNTYVMz9rCQVSxd0cLlqsJauko79NnwQJbzDTyLooYoIwz75qBOH2/xOMirpeEqRJrF/EQjWekJmGk9RtboXePu2rka+Xm51syBPhiXJAq0GfbfaFu9tNqs/e2Vjja/ltE1M0lqvxfXQ6da6HrThsm5id4ClZFIi0acRfrsPLRixS/IQYtksxghvJwbqOsbIsITail9Ayy4tKcogeEZiOO+4Ed264NSKmk7l3wKwJLAFjCFogBx8GE3OBz4pqcAn/ydA=</p>
     * @param timestamp 时间戳.
     *                  <p>e.g.: 20160729142400</p>
     * @param seq 序列.
     *            <p>e.g.: 0001</p>
     * @param charset 字符集.
     * @return 验证结果.
     */
    public static boolean verify(String key, byte[] sign, String operatorID, String data, String timestamp, String seq, Charset charset){
        return verify(key, sign, assembleData(operatorID,data, timestamp, seq, charset));
    }


    /**
     * 验证数据包.
     * @param key 密钥.
     * @param signData 签名.
     * @param data 数据.
     * @return 验证是否成功.
     */
    public static boolean verify(String key, byte[] signData, byte[] data){
        return withSign((fac, sign)->{
            try {
                KeySpec keySpec = HMacMd5KeySpec.of(key);
                PublicKey pub = fac.generatePublic(keySpec);
                sign.initVerify(pub);
                sign.update(data);
                return sign.verify(signData);
            } catch (InvalidKeyException|InvalidKeySpecException | SignatureException e) {
                throw new RuntimeException(e);
            }
        });
    }

    /**
     * 验证数据包.
     * @param key 密钥.
     * @param signData 签名.
     * @param data 数据.
     * @return 验证是否成功.
     */
    public static boolean verifyHex(String key, String signData, byte[] data){
        return verify(key, HexUtils.hexString2Bytes(signData), data);
    }

    /**
     * 在密钥工厂和签名算法确定的条件下,执行回到函数.
     * @param callback 要执行的回调函数.
     * @param <T> 回调函数返回值类型.
     * @return 回调函数返回值.
     */
    private static <T> T withSign(BiFunction<KeyFactory, Signature, T> callback){
        try {
            KeyFactory fac = KeyFactory.getInstance("HMacMd5");
            Signature sign = Signature.getInstance("HMacMd5");

            return callback.apply(fac, sign);

        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }


}

测试

测试过程代码如下:


    @Test
    public void test3(){

        TestCase.assertEquals(want, HMacMd5Utils2.signHex(key, opId, data, ts, seq));
        TestCase.assertEquals(want, HMacMd5Utils2.signHex(key, opId, data, ts, seq, StandardCharsets.UTF_8));
        TestCase.assertTrue(HMacMd5Utils2.verifyHex(key, want, opId, data, ts, seq));
        TestCase.assertTrue(HMacMd5Utils2.verifyHex(key, want, opId, data, ts, seq, StandardCharsets.UTF_8));

    }


END

  • 10
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值