前面章节实现的几个简单的接口,有个问题,就是不管是服务端还是客户端,都无法保证自己收到的数据确实是对方发送的,没有被第三方篡改的。

比如这个请求:

curl -v 'http://127.0.0.1:8080/testGet?p=testp'
1

当服务端接收到参数p的时候,拿到是值是testp,那有没有可能客户端请求的时候,传的是testa呢,怎么证明客户端请求的时候,值确实是testp呢。

同样,服务端响应的数据,客户端目前也无法证明服务端响应的数据是合法的。

# 数据加签

为了解决这类问题,我们可以给数据加上一个签名,比如采用MD5最简单的签名方式,可以把请求参数当作待签名的数据,计算一个MD5值,然后传给服务端,服务端同样用参数生成MD5值进行比对,发现不一样,那说明数据被篡改过了。

数据加签的机制是没有问题的,并且确实可以解决防篡改的问题,但是算法和机制要选好。

无论是MD5、HMAC、还是其它类型的摘要算法,都有一个问题,就是篡改者要是知道了算法,那还是可以篡改。所以要选用非对称算法来做数据的加签与验签,这样就算篡改者知道了算法,但是没有密钥也篡改不了,因为非对称的机制就是用私钥签名,公钥验签,公钥可以公开出去。

比如客户端用客户端的私钥对请求参数进行签名,并把公钥给到服务端,服务端在收到请求后,用客户端的公钥进行验签。就算篡改者知道了客户端的公钥,也无法对请求数据进行篡改,此时客户端的重心就转移到了,如何保护它的私钥问题上了,而不用担心数据会被别人篡改。

提示

签名是只能由私钥生成,公钥验签。公钥是无法生成签名,同时使用公钥验的。这就是非对称算法的好处。

# 双签

同样的,服务端把自己的公钥给到客户端,服务端在响应数据的时候,进行签名,客户端进行验签,这就是双签。即客户请求时,使用客户端的私钥签名,服务端验,服务端响应时,使用服务端的私钥签名,客户端验。来确保数据交互时请求和响应都不会被篡改。

# 常用算法

常用的算法有RSA1024、RSA2048、SM2,当然还有一些其它ECC类算法。早期大家用的基本是RSA1024,现在大部分都用2048或更长的密钥来生成签名了。当然也有用国密的,不过用的少,像我们公共交通行业用的多,还有就是国企。我现在设计系统,基本是全国密体系了,除了一些要给第三方调用的,会做RSA+SM2,就是任选其一。

SM2有个好处就是生成的签名是64字节的,RSA的话,密钥越长,签名值就越长,计算复杂度也越高,系统负载当然也会跟着上去。国密应用这么多年了,安全性还是不用担心的。

接下来,我们就用国密来实现数据的加签,实战一波。

# SM2算法

关于SM2密钥的生成,大家可以看看SM2密钥相关,这篇文章。

SM2签名及验签,可以先看看SM2签名及验签,这篇文章。

# 加签数据设计

# GET请求

对于GET类的请求,我们可以将参数先按字母进行一个排序,然后把他们拼装起来,比如:

curl -v 'http://127.0.0.1:8080/testGet?p=testp&ab=123&x=456'
1

那么参数排序,并拼装后变成:

ab=123&p=testp&x=456
1

# POST请求

POST如果是FORMDATA形式,我们也可以像GET请求一样,先将参数进行排序,再拼装,比如:

curl -v -H 'Content-Type: application/x-www-form-urlencoded;charset=UTF-8' --data-binary "p=test1&ab=123&x=456" 'http://127.0.0.1:8080/testPostFromdata'
1

同时按照GET的参数拼装方式,拼装后变成:

ab=123&p=test1&x=456
1

# POST BODY

对于传输的数据是BODY形式的,我们可以约定一种拼装方式,把BODY原封不动的作为entity的值来拼接,如:

curl -v -H 'Content-Type: application/json' --data-binary '{
>     "pInt": 1,
>     "pBoolean": false,
>     "pString": "ssss"
> }' 'http://127.0.0.1:8080/testPostBody'
1
2
3
4
5

拼接后变成:

entity={"pInt": 1,"pBoolean": false,"pString": "ssss"}
1

这里entity的值就是BODY的内容,原封不动,有换行也是要体现,这里为了方便大家查看,换行符我就去掉了。

# 问题一

有了这个规则之后呢,要签名的数据是有了,但大家有没发现一个问题,如果请求参数是空的,咋办,没有数据可签肯定也不行。

# 问题二

有没有发现testGet和testPostFromdata的参数个数是一样的,参数名也一样,那是不是有了签名值,别的老六就可以作文章了。

所以呀,为了解决上面的两个问题,我们可以把要请求的接口地址也加到待签名的数据里去。比如:

curl -v 'http://127.0.0.1:8080/testGet?p=testp&ab=123&x=456'
1

可以约定接口地址的键为path,并且不参与排序,直接拼在后面,那么上述请求的待签数据是:

ab=123&p=testp&x=456&path=/testGet
1

这样是不是即解决了参数可能为空,又避免了别人用同样的参数和签名值去访问其它接口的问题。

# 签名值存放

关于签名值放置的位置,可以根据大家的需要,放Header里或公共参数都可以。

# 关于算法

关于SM2签名算法有几点跟大家提一提。

  • 第一:签名肯定都是私钥签名,公钥验签。
  • 第二:验签的复杂度比签名高,因为验签有两次点乘一次点加,而签名是一次点乘。
  • 第三:签名如果只有公钥的话,要多一次点乘,即从私钥生成公钥,所以服务器写签名算法,就把公私钥都保存起来,可以省掉一次点乘。
  • 第四:签名算法在计算z的时候,要传入userid,默认都是1234567812345678。这里如果是非标的话,可以定制和作文章,加大安全性。
  • 第五:签名需要随机生成大数 in [1, n - 1],不要偷懒,当然别人封装好的,一般都会处理。

# 集群部署

通常为满足集群部署,水平扩展的要求,我们需要把用户用于验签相关的内容缓存到内存中(比如Redis),以减少数据库这方面的查询压力。

# 注意事项

  • 第一:对于GET类的请求,通常的参数的值可能需要经过encodeURI处理,这里指的是拼装待签名数据的时候,请求传参时不需要。
  • 第二:对于POST类请求,在写拦截器的时候,要定制InputStream,因为拦截验签的时候,会先把BODY读出来,所以处理完要写回去,或采用其它类似方法处理。
  • 第三:这类非功能性需求,如果条件允许,可以将缓存用户公钥这类服务独立出来,增加服务的可用性。

# 降级处理

针对Redis不可用的降级处理呢,我个人想法是这样的,可以事先根据用户的公钥,使用服务端的私钥生成一个证书,下发给客户端,比如证书内容为:

序号 字段名 字段类型 长度(单位字节) 备注
1 用户标识 ANSI N 用户唯一标识
2 证书签发时间 HEX 4 UTC时间戳,单位秒
3 证书失效时间 HEX 4 UTC时间戳,单位秒
4 密钥索引 HEX 1 使用服务端的哪个私钥签发的
5 客户端公钥 HEX 33 压缩SM2公钥
6 数字签名 HEX 64 使用指定索引下的私钥对1-5进行签名

一旦发生Redis不可用,防篡改这块业务需要降级处理的话,就让客户端在请求时,将证书也发过来,服务端先验证书,证书有效的情况下,用证书里的公钥验客户端签名。

这种方法呢,会增加客户端的流量、服务端流量以及服务器的负载。

大家如果有更好的办法或者觉得我这样设计有什么毛病的,欢迎加QQ群讨论:859626351