客户端与服务端通讯时参数加签加密的方案选择

前言

现在需要开发一个类似于授权中心的平台,后续会有很多应用接入该平台,以进行数据的交换。
在开发该平台时,需要考虑每次通讯时的签名、加密问题,但由于该平台是在内部使用,暂时不会连接到公网,所以现在需要一种简单方便的签名、加密方案。

需求分析

应用凭证

平台与应用是上下级(客户端与服务端)的关系,所以在应用注册时我们引入appid和appkey的概念,appid是可公开的、用于通讯和申请资源的凭证,appkey是应用私有的、不可公开的、用于各种敏感操作的密钥。
这样就能保证每个应用都有单独的密钥,且该密钥只有平台和应用方知道。

请求参数

请求参数中需要携带原有的业务参数,加上签名与appid,签名是为了校验参数合法性,appid是为了使得应用方与平台方能找到对应的密钥,并基于密钥完成签名的生成。

解决方案

基础方案:应用id+参数明文+签名

我们知道由MD5算法生成的签名是不可逆的,而应用密钥(appkey)可看作是第三方无法获取的。
这里我们选择将签名原文的生成规则设置为:所有参数的字符串格式拼接appkey。

应用id

假设appid的值为id_1234,appkey的值为 key_abcd

参数明文

假设需要传输的参数为:

"appid": "id_1234"
"name": "张三"
"tel": "13812345678"

对于 http get 请求,加签前原本的参数传输格式为:
appid=id_1234&name=张三&tel=13812345678
对于 http post 请求,发送时一般需要传输Json格式的对象,那么此处加签前的参数传输格式为:
{"appid": "id_1234","name":"张三","tel":"13812345678"}

签名

数字签名的定义:
数字签名是一种将相当于现实世界中的盖章、签字的功能在计算机世界中进行实现的技术。使用数字签名可以识别篡改和伪装,还可以防止否认。

签名原文需要再参数明文的基础上,通过自定义的拼接格式将应用方私有的密钥信息拼接进去。
Get请求举例:name=张三&tel=13812345678+&appkey=+key_abcd
Post请求举例:{"appid": "id_1234","name":"张三","tel":"13812345678"}+&appkey=+key_abcd
这里边的第二个字符串部分&appkey=是自定义的拼接固定格式,我们完全可以换成任意的别的格式,这部分留空也可以(即参数直接拼接密钥)。
总而言之,签名原文 = 参数明文 + 固定后缀 + 应用私钥
假设对上述举例的签名原文使用MD5算法,得出的签名为:56282c3eabe16c5945c7287ded2b45de

最终请求参数

在原有参数的基础上增加appid与计算出的签名,所以最终发送的参数为:

// 即:请求参数 = 应用id + 参数明文 + 签名
// Get请求参数
appid=id_1234&name=张三&tel=13812345678&sign=56282c3eabe16c5945c7287ded2b45de
// Post请求参数
{"appid": "id_1234","name":"张三","tel":"13812345678","sign":"56282c3eabe16c5945c7287ded2b45de"}

总结

平台方在接收到该参数时,依据appid查出应用的appkey,根据appkey和上文中生成签名的规则,同样计算出一个签名B,如果签名B与接收到的参数中的sign一致,可视为是合法的、未被篡改的请求。

优点

优点在于可以防止传输的内容被篡改。签名的生成依赖明文参数和应用私有的密钥,所以任何对明文参数的单独修改都会导致接收方计算出不同的签名,那么接收方就可以视本次请求为被篡改的无效请求。

缺点

很明显的一个缺点是,核心的请求参数是暴露的,比如本例中的nametel是外部可以拦截并查看到的。
另一个缺点就是加签的请求虽然可以保证不被篡改,但是无法避免重放攻击,即当第三方获取完整的请求体后可以保存后再次发出,而接收方并没有什么手段可以证明该请求来自于合法用户。

代码示例
// 建议将签名工具写在一个单独的项目中,这样应用方和平台可以同时引用该工具进行加签验签
import com.alibaba.fastjson.JSONObject;
import cn.hutool.crypto.SecureUtil;
/**
 * @author: csdn@Ka_ze
 * @date: 2023/8/9
 */
public class SignUtil {
    
    

    /**
     * 获取加签后的 jsonObject
     *
     * @param salt       用于加签的特殊值
     * @param jsonObject post请求参数的jsonObject格式
     * @return
     */
    public static JSONObject addSign(String salt, JSONObject jsonObject) throws Exception {
    
    
        if (ObjectUtil.isEmpty(jsonObject)) {
    
    
            throw new Exception("jsonObject is empty");
        }
        // json对象格式加签前的明文:调用json对象的toStirng方法
        String sign = getSign(salt, jsonObject.toString());
        jsonObject.put("sign", sign);
        return jsonObject;
    }

    /**
     * 获取加签后的参数字符串
     *
     * @param salt  用于加签的特殊值
     * @param param get请求参数的String格式,应该为key1=value1&key2=value2
     * @return
     * @throws Exception
     */
    public static String addSign(String salt, String param) throws Exception {
    
    
        if (ObjectUtil.isEmpty(param)) {
    
    
            throw new Exception("param is empty");
        }
        // 参数格式检测
        List<String> paramList;
        try {
    
    
            paramList = Arrays.asList(param.split("&"));
        } catch (Exception e) {
    
    
            e.printStackTrace();
            throw new Exception("parameter parsing exception");
        }
        if (ObjectUtil.isEmpty(paramList)) {
    
    
            throw new Exception("param is empty");
        }
        AtomicBoolean isFormatError = new AtomicBoolean(false);
        paramList.forEach(paramStr -> {
    
    
            String[] split = paramStr.split("=");
            if (split.length!=2) {
    
    
                isFormatError.set(true);
            }
        });
        if (isFormatError.get()) {
    
    
            throw new Exception("parameter format exception");
        }
        // 获取签名
        String sign = getSign(salt, param);
        return param + "&sign=" + sign;
    }

    /**
     * 获取签名
     *
     * @param salt      签名源文中的特殊字符串
     * @param plaintext 签名源文
     * @return
     */
    public static String getSign(String salt, String plaintext) throws Exception {
    
    
        if (ObjectUtil.isEmpty(plaintext)) {
    
    
            throw new Exception("plaintext is null");
        }
        // 此处签名原文的生成规则为:参数明文直接拼接应用密钥
        String signSourceStr = plaintext + salt;
        return SecureUtil.md5(signSourceStr);
    }

    /**
     * 校验jsonObject中的签名
     * @param salt 签名特殊值
     * @param jsonObject 接收到的参数jsonObject对象
     * @return
     * @throws Exception
     */
    public static boolean checkSign(String salt, JSONObject jsonObject) throws Exception {
    
    
        if (ObjectUtil.isEmpty(jsonObject)) {
    
    
            throw new Exception("jsonObject is null");
        }
        // 验签时先获取sign,然后将原jsonObject中的sign字段去除,用以获得加签前的参数原文
        String sign = jsonObject.getString("sign");
        String plaintext = jsonObject.fluentRemove("sign").toJSONString();

        if (ObjectUtil.isEmpty(sign)) {
    
    
            return false;
        }

        if (sign.equals(getSign(salt, plaintext))) {
    
    
            return true;
        } else {
    
    
            return false;
        }
    }

    /**
     * 校验字符串参数的签名
     * @param salt 签名特殊值
     * @param map 接收到的参数map
     * @return
     * @throws Exception
     */
    public static boolean checkSign(String salt, LinkedHashMap<String, String> map) throws Exception {
    
    
        if (ObjectUtil.isEmpty(map)) {
    
    
            throw new Exception("map is null");
        }
        String sign = null;
        StringBuilder builder = new StringBuilder();
        String plaintext = null;
        // 动态拼接参数map,以还原加签前的参数原文
        for (String key : map.keySet()) {
    
    
            String value = map.get(key);
            if ("sign".equals(key)) {
    
    
                sign = value;
            } else {
    
    
                builder.append(key + "=" + value + "&");
            }
        }

        if (ObjectUtil.isEmpty(sign)) {
    
    
            return false;
        }

        plaintext = builder.toString();
        plaintext = plaintext.substring(0, plaintext.length()-1);
        if (sign.equals(getSign(salt, plaintext))) {
    
    
            return true;
        } else {
    
    
            return false;
        }
    }
}

升级方案:应用id+密文(参数明文+唯一标识+时间戳)

上文中我们使用md5摘要算法作为签名,用以证明请求体未被篡改,但明文的参数与无法避免重放攻击的缺点无法被忽略,下边我们尝试解决这两个问题。

解决重放攻击

解决重放攻击有点像解决接口的幂等性问题,都是为了避免合法参数多次请求。

方案1:增加唯一流水号

引入流水号,发送方在参数中新增一个流水号,接收方在解密之后处理业务逻辑前进行流水号的唯一性验证,验证通过后再进行业务逻辑的处理,最终将本次请求参数中的流水号存储入库
这样做能免疫重放攻击,但缺点就是引入了数据库,并存储所有请求的流水号,增加请求验证时间的同时也增加了数据库负担

方案2:增加时间戳

引入时间戳,发送方在请求体中加入当前时间,接收方解密请求后验证请求体中的时间是否过期,过期则丢弃
假设过期时间设定为5分钟,这样做可以保证5分钟后的请求被丢弃,但不能解决5分钟内的多次请求问题。且如果接收方与发送方的本地时钟存在差异,设定过短的过期时间可能会使接收方接收不到请求。

方案3:唯一标识+时间戳

结合上边两种方案,我们用流水号来避免短时间内的重复请求,用时间戳来规避长时间内的重复请求
发送方在请求体中增加唯一标识(随机字符或根据业务参数生成),增加时间戳。
假设过期时间设定为5分钟,接收方解密请求后先验证时间戳,5分钟外的请求丢弃,然后验证唯一标识。
这里我们可以将唯一标识设置缓存到Redis上,并设置过期时间。接收方查询缓存中是否存在唯一标识,不存在则说明首次处理该请求,则放行进入后续业务流程;存在则说明已处理过该请求,将请求丢弃。
服务端请求解析流程图

生成密文

我们知道AES是对称加密算法的一种,通过密钥对参数加密并获取密文,所以我们可以将参数原文进行AES加密,将生成的密文作为传输对象,这样就解决了参数明文的问题
同时,AES加密使用的密钥我们认为是应用私有的、不被泄露的,所以对实际请求体的篡改(无论是appid还是时间戳),都无法正常解密出原文,这也满足了签名可以识别篡改的特性

总结

使用应用id+密文(参数明文+唯一标识+时间戳)的方式可以保证参数加密传输、避免重放攻击,是比较理想的客户端与服务端的通讯解决方案。 此外,该种方案仍有可升级空间,比如加密方式也可以换成非对称加密、appid可以不明文传输、密文中设置单独签名等等。感兴趣的读者可以阅读有关RSA+AES加密方案的相关文章,这里不再展开详谈。

参考文章

java接口签名(Signature)实现方案
什么是重放攻击
RSA + AES加密原理,一线大厂主流的加密手段

猜你喜欢

转载自blog.csdn.net/Ka__ze/article/details/132110093