Open APIs encrypt Method V1.1

非对称密码加密解密&签名机制&重放攻击

《Internal APIs encrypt Method V1.0》 文章中已经描述了我们正在线上产品所用的加密算法,实际上他不仅仅是解决防参数篡改的问题,同时也解决了数据隐私保护的问题。即便如此这套算法还是有漏洞的,也是做所以定义为 Internal APIs的主要原因了,由于只提供给内部使用所以安全性暂且得到保障,一旦提供给外部使用则全盘”露馅”了,这也是我为什么要编辑此篇《Open APIs encrypt Method V1.1》文章了。

其中最为致命的问题是要在客户端代码中公开自己的密钥以及算法,一旦黑客从客户端程序中反编译源码,这样就会导致整个加密体系的崩盘,而且连补救的措施都没有。

同时第二个比较严重的问题就是黑客从放攻击的问题,黑客在抓取到包体后,直接再次提交请求导致服务器端不断受到重复请求。

所以基于以上两点,我们了解到《Internal APIs encrypt Method V1.0》所存在的问题如下表所示

问题描述 安全性 解决方案
脱敏(数据隐私保护) 安全
完整性 (防参数篡改) 安全
重放攻击 (重复提交) 未解决 timestamp + nonce
对称密钥 不够安全 非对称密钥

签名机制:

所以本文重点就是要解决 重放攻击 (重复提交) & 密钥安全 这两个问题。在此之前再介绍一种常用的解决数据传输过程中确保完整性 (防参数篡改)的解决方案,过程如下:

  1. 客户端使用约定好的秘钥对传输参数进行加密,得到签名值signature,并且将签名值也放入请求参数中,发送请求给服务端
  2. 服务端接收客户端的请求,然后使用约定好的秘钥对请求的参数(除了signature以外)再次进行签名,得到签名值autograph。
  3. 服务端对比signature和autograph的值,如果对比一致,认定为合法请求。如果对比不一致,说明参数被篡改,认定为非法请求。

Eg.客户端 zhangsan 执行登录请求

将要发送的数据:
apihost?action=login&username=zhangsan&password=123456&sign=

签名算法:(java 实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//1.对除sign外的所有参数按字典排序 对所有待签名参数按照字段名的 ASCII码从小到大排序(字典序)后		
String[] arr = new String[] { "username=zhangsan","password=123456", "action=login"};
Arrays.sort(arr);

//2.将排序后的结果拼接成一个字符串(即key1=value1&key2=value2…)
String content = arr[0].concat("&"+arr[1]).concat("&"+arr[2]);

//3、将字符串进行sha1加密得到sign值
MessageDigest md = null;
String sign = null;
try {
md = MessageDigest.getInstance("SHA-1");
byte[] digest = md.digest(content.toString().getBytes());
sign = StrUtil.byteToStr(digest);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
System.out.println("sign="+sign);

输出的sign为:
sign=ebb36366bd9d8656e2327ca913a4f854f35a0e95

最终请求服务器的数据为:

apihost?action=login&username=zhangsan&password=123456&sign=ebb36366bd9d8656e2327ca913a4f854f35a0e95

当服务器拿到数据后再进行同样的算法然后再匹配sign值是否一致,如果对比一致,认定为合法请求。如果对比不一致,说明参数被篡改,认定为非法请求。

注意事项:

  1. 参数排序很重要,不同的排序会导致签名值不一致。所以要事先规定好排序算法。
  2. sign算法也要事先规定好,示例中是一个简单的算法并没有使用到密钥。实际过程中可能更加复杂签名的秘钥我们可以使用很多方案,可以采用对称加密或者非对称加密。
  3. 因为黑客不知道签名的秘钥,也不知道签名的算法,所以即使截取到请求数据,对请求参数进行篡改,但是却无法对参数进行签名,无法得到修改后参数的签名值signature。
  4. 示例中并没有解决数据脱敏的问题,应用过程中可以根据实际情况再进行脱敏算法。

防止重放攻击

基于timestamp的方案

每次HTTP请求,都需要加上timestamp参数,然后把timestamp和其他参数一起进行数字签名。因为一次正常的HTTP请求,从发出到达服务器一般都不会超过60s,所以服务器收到HTTP请求之后,首先判断时间戳参数与当前时间相比较,是否超过了60s,如果超过了则认为是非法的请求。

一般情况下,黑客从抓包重放请求耗时远远超过了60s,所以此时请求中的timestamp参数已经失效了。

如果黑客修改timestamp参数为当前的时间戳,则signature参数对应的数字签名就会失效,因为黑客不知道签名秘钥,没有办法生成新的数字签名。

但这种方式的漏洞也是显而易见的,如果在60s之后进行重放攻击,那就没办法了,所以这种方式不能保证请求仅一次有效。

基于nonce的方案

nonce的意思是仅一次有效的随机字符串,要求每次请求时,该参数要保证不同,所以该参数一般与时间戳有关,我们这里为了方便起见,直接使用时间戳的16进制,实际使用时可以加上客户端的ip地址,mac地址等信息做个哈希之后,作为nonce参数。

我们将每次请求的nonce参数存储到一个“集合”中,可以json格式存储到数据库或缓存中。

每次处理HTTP请求时,首先判断该请求的nonce参数是否在该“集合”中,如果存在则认为是非法请求。

nonce参数在首次请求时,已经被存储到了服务器上的“集合”中,再次发送请求会被识别并拒绝。

nonce参数作为数字签名的一部分,是无法篡改的,因为黑客不清楚token,所以不能生成新的sign。

这种方式也有很大的问题,那就是存储nonce参数的“集合”会越来越大,验证nonce是否存在“集合”中的耗时会越来越长。我们不能让nonce“集合”无限大,所以需要定期清理该“集合”,但是一旦该“集合”被清理,我们就无法验证被清理了的nonce参数了。也就是说,假设该“集合”平均1天清理一次的话,我们抓取到的该url,虽然当时无法进行重放攻击,但是我们还是可以每隔一天进行一次重放攻击的。而且存储24小时内,所有请求的“nonce”参数,也是一笔不小的开销。

基于timestamp和nonce的方案

nonce的一次性可以解决timestamp参数60s的问题,timestamp可以解决nonce参数“集合”越来越大的问题。
防止重放攻击一般和防止请求参数被串改一起做,请求的Headers数据如下图所示。

我们在timestamp方案的基础上,加上nonce参数,因为timstamp参数对于超过60s的请求,都认为非法请求,所以我们只需要存储60s的nonce参数的“集合”即可。

HTTP请求头,参数说明:

由于每次数据请求都要带上这几个参数,所以直接将这几个参数设置在请求头中,从而简化body长度;当然也可以在去请求参数中拼凑视具体情况而定。

参数名 参数说明 备注
token 用户令牌,用于认证用户身份 稍微长一点的文本
sign 签名,用于监测请求数据的完整性 中等文本
timestamp 请求时间戳 根据的约定的保留毫秒或者精度到秒
nonce 请求随机字符串: MD5(时间戳+随机字符) 单位时间内产生不重复字符就好

有关token的详细说明,请查看《Access Token & Refresh Token 机制》这篇文章

服务器端校验:

服务器端的校验配置通常会放在Filter / Interceptor 中,作为全局的管理。
nonce参数通常会存在redis中,并且设置TTL过期时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
String token = request.getHeader("token");
String timestamp = request.getHeader("timestamp");
String nonce = request.getHeader("nonce");
String sign = request.getHeader("sign");

//时间限制配置 s
int timeLimit = 60;

//请求头参数非空验证
if (StringUtils.isEmpty(token) || StringUtils.isEmpty(timestamp) || StringUtils.isEmpty(nonce) || StringUtils.isEmpty(sign)) {
ctx.setResponseBody(JSON.toJSONString(new Result("-1", "请求头参数不正确")));
return null;
}

//请求时间和现在时间对比验证,发起请求时间和服务器时间不能超过timeLimit秒
if (StringUtils.timeDiffSeconds(new Date(), timestamp) > timeLimit) {
ctx.setResponseBody(JSON.toJSONString(new Result("-1", "请求发起时间超过服务器限制")));
return null;
}

//验证用户信息
UserInfo userInfo = UserInfoUtil.getInfoByToken(token);
if (userInfo == null) {
ctx.setResponseBody(JSON.toJSONString(new Result("-1", "错误的token信息")));
return null;
}

//验证相同noce的请求是否已经存在,存在表示为重复请求
if (NoceUtil.exsit(userInfo, nonce)) {
ctx.setResponseBody(JSON.toJSONString(new Result("-1", "重复的请求")));
return null;
} else {
//如果noce没有在缓存中,则需要加入,并设置过期时间为timeLimit秒
NoceUtil.addNoce(userInfo, nonce, timeLimit);
}

//服务器生成签名与header中签名对比
String serverSign = SignUtil.getSign(userinfo, token, timestamp, nonce, request);
if (!serverSign.equals(sign)) {
ctx.setResponseBody(JSON.toJSONString(new Result("-1", "错误的签名信息")));
return null;
}

非对称密钥

拓扑结构

与对称密钥不同,非对称密钥要有一组密钥分别是公钥和私钥,通常情况下公钥加密,私钥解密。公钥可以发布给任意的客户端程序,服务器端则通过私钥解密。以下是非对称密钥的拓扑结构。

如上图所以,发送者用接收方公开出来的公钥PK进行加密。接受方在收到密文后,再用与公钥对应的私钥SK进行解密。同样,密文即便被截获,但是由于截获者只有公钥,没有私钥,他不能进行解密

非对称加密优缺点

非对称加密的突出优点是用于解密的密钥(也就是私钥)永远不需要传递给对方。但是,它的缺点也很突出:非对称加密算法复杂,导致加解密速度慢,故只适合小量数据的场合。而对称加密加解密效率高,系统开销小,适合进行大数据量的加解密。由于文件一般比较大,这个特性决定了适合它的加密方式最好是对称加密。

Eg.RSA对称密钥算法(java实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232

package com.fanfq.util.commons.encrypt.rsa;

import org.apache.commons.codec.binary.Base64;

import javax.crypto.Cipher;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.HashMap;
import java.util.Map;


/**
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.11</version>
</dependency>
https://www.jianshu.com/p/ff8281f034f4
* @author fred
*
*/

public class RSAUtil2 {
public static final String KEY_ALGORITHM = "RSA";
public static final String SIGNATURE_ALGORITHM = "MD5withRSA";

private static final String PUBLIC_KEY = "RSAPublicKey";
private static final String PRIVATE_KEY = "RSAPrivateKey";

public static byte[] decryptBASE64(String key) {
return Base64.decodeBase64(key);
}

public static String encryptBASE64(byte[] bytes) {
return Base64.encodeBase64String(bytes);
}

/**
* 用私钥对信息生成数字签名
*
* @param data 加密数据
* @param privateKey 私钥
* @return
* @throws Exception
*/
public static String sign(String data, String privateKey) throws Exception {
// 解密由base64编码的私钥
byte[] keyBytes = decryptBASE64(privateKey);
// 构造PKCS8EncodedKeySpec对象
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes);
// KEY_ALGORITHM 指定的加密算法
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
// 取私钥匙对象
PrivateKey priKey = keyFactory.generatePrivate(pkcs8KeySpec);
// 用私钥对信息生成数字签名
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
signature.initSign(priKey);
signature.update(data.getBytes());
return encryptBASE64(signature.sign());
}

/**
* 校验数字签名
*
* @param data 加密数据
* @param publicKey 公钥
* @param sign 数字签名
* @return 校验成功返回true 失败返回false
* @throws Exception
*/
public static boolean verify(String data, String publicKey, String sign)
throws Exception {
// 解密由base64编码的公钥
byte[] keyBytes = decryptBASE64(publicKey);
// 构造X509EncodedKeySpec对象
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
// KEY_ALGORITHM 指定的加密算法
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
// 取公钥匙对象
PublicKey pubKey = keyFactory.generatePublic(keySpec);
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
signature.initVerify(pubKey);
signature.update(data.getBytes());
// 验证签名是否正常
return signature.verify(decryptBASE64(sign));
}

public static byte[] decryptByPrivateKey(byte[] data, String key) throws Exception{
// 对密钥解密
byte[] keyBytes = decryptBASE64(key);
// 取得私钥
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
Key privateKey = keyFactory.generatePrivate(pkcs8KeySpec);
// 对数据解密
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.DECRYPT_MODE, privateKey);
return cipher.doFinal(data);
}

/**
* 解密<br>
* 用私钥解密
*
* @param data
* @param key
* @return
* @throws Exception
*/
public static byte[] decryptByPrivateKey(String data, String key)
throws Exception {
return decryptByPrivateKey(decryptBASE64(data),key);
}

/**
* 解密<br>
* 用公钥解密
*
* @param data
* @param key
* @return
* @throws Exception
*/
public static String decryptByPublicKey(String data, String key)
throws Exception {
byte[] datas = decryptBASE64(data);
// 对密钥解密
byte[] keyBytes = decryptBASE64(key);
// 取得公钥
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
Key publicKey = keyFactory.generatePublic(x509KeySpec);
// 对数据解密
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.DECRYPT_MODE, publicKey);
return new String(cipher.doFinal(datas));
}

/**
* 加密<br>
* 用公钥加密
*
* @param data
* @param key
* @return
* @throws Exception
*/
public static String encryptByPublicKey(String data, String key)
throws Exception {
// 对公钥解密
byte[] keyBytes = decryptBASE64(key);
// 取得公钥
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
Key publicKey = keyFactory.generatePublic(x509KeySpec);
// 对数据加密
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
return encryptBASE64(cipher.doFinal(data.getBytes()));
}

/**
* 加密<br>
* 用私钥加密
*
* @param data
* @param key
* @return
* @throws Exception
*/
public static String encryptByPrivateKey(String data, String key)
throws Exception {
// 对密钥解密
byte[] keyBytes = decryptBASE64(key);
// 取得私钥
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
Key privateKey = keyFactory.generatePrivate(pkcs8KeySpec);
// 对数据加密
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
return encryptBASE64(cipher.doFinal(data.getBytes()));
}

/**
* 取得私钥
*
* @param keyMap
* @return
* @throws Exception
*/
public static String getPrivateKey(Map<String, Key> keyMap)
throws Exception {
Key key = (Key) keyMap.get(PRIVATE_KEY);
return encryptBASE64(key.getEncoded());
}

/**
* 取得公钥
*
* @param keyMap
* @return
* @throws Exception
*/
public static String getPublicKey(Map<String, Key> keyMap)
throws Exception {
Key key = keyMap.get(PUBLIC_KEY);
return encryptBASE64(key.getEncoded());
}

/**
* 初始化密钥
*
* @return
* @throws Exception
*/
public static Map<String, Key> initKey(int length) throws Exception {
KeyPairGenerator keyPairGen = KeyPairGenerator
.getInstance(KEY_ALGORITHM);
if(length != 2048) length = 1024;
keyPairGen.initialize(length);
KeyPair keyPair = keyPairGen.generateKeyPair();
Map<String, Key> keyMap = new HashMap(2);
keyMap.put(PUBLIC_KEY, keyPair.getPublic());// 公钥
keyMap.put(PRIVATE_KEY, keyPair.getPrivate());// 私钥
return keyMap;
}

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package com.fanfq.util.commons.encrypt.rsa;

import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.security.Key;
import java.util.Map;

public class RSAUtil2Test {

private String publicKey;
private String privateKey;

public void setUp() throws Exception {
Map<String, Key> keyMap = RSAUtil2.initKey(2048);
publicKey = RSAUtil2.getPublicKey(keyMap);
privateKey = RSAUtil2.getPrivateKey(keyMap);
System.out.println("公钥:" + publicKey.length());
System.out.println(publicKey);
System.out.println("私钥:" + privateKey.length());
System.out.println(privateKey);

ObjectOutputStream oos1 = null;
ObjectOutputStream oos2 = null;
try {
/** 用对象流将生成的密钥写入文件 */
oos1 = new ObjectOutputStream(new FileOutputStream("RSA_PUBLIC_KEY"));
oos2 = new ObjectOutputStream(new FileOutputStream("RSA_PRIVATE_KEY"));
oos1.writeObject(publicKey);
oos2.writeObject(privateKey);
} catch (Exception e) {
throw e;
} finally {
/** 清空缓存,关闭文件输出流 */
oos1.close();
oos2.close();
}
}

public void test() throws Exception {
System.out.println("\n-------------");
System.out.println("公钥加密——私钥解密");
String inputStr = "dounine";
String encodedData = RSAUtil2.encryptByPublicKey(inputStr, publicKey);
byte[] decodedData = RSAUtil2.decryptByPrivateKey(encodedData,
privateKey);
String outputStr = new String(decodedData);
System.out.println("加密前: " + inputStr);
System.out.println("公钥加密后: " + encodedData);
System.out.println("私钥解密后: " + outputStr);
}

public void testSign() throws Exception {
System.out.println("\n-------------");
System.out.println("私钥加密——公钥解密");
String inputStr = "dounine";
String encodedData = RSAUtil2.encryptByPrivateKey(inputStr, privateKey);
String decodedData = RSAUtil2.decryptByPublicKey(encodedData, publicKey);
System.out.println("加密前: " + inputStr);
System.out.println("私钥加密后: " + encodedData);
System.out.println("公钥解密后: " + decodedData);

System.out.println("\n私钥签名——公钥验证签名");
// 产生签名
String sign = RSAUtil2.sign("123", privateKey);
System.out.println("私钥签名:("+sign.length()+")" + sign);
// 验证签名
boolean status = RSAUtil2.verify(encodedData, publicKey, sign);
System.out.println("公钥验证签名:" + status);
}

public static void main(String[] args) {
// TODO Auto-generated method stub
RSAUtil2Test rsa = new RSAUtil2Test();

try {
rsa.setUp();
rsa.test();
rsa.testSign();
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

}

}
Fred范方青 wechat
项目合作请联系我私人微信: fredtv23
0%