现阶段移动应用主要通过Restful API请求的方式与后端进行交互,这就导致了不少问题。客户端的请求可能通过各种的方式被破解,比如抓包,反编译等。由此服务端将会面临巨大的安全风险,造成被恶意攻击,服务过载瘫痪,数据泄密等问题。
当然,规避问题的方式有很多,如升级https,服务端做IP请求次数限制,请求加密验证等等。本文以Android移动端为例介绍一种请求加密的方式,能够有效的在服务端和客户端间进行加密鉴权,确保请求合法性,降低服务端的安全风险。
以APK签名为密钥
无论对称非对称加密,都逃离不了密钥体系,如何寻找一个安全可靠的密钥成了重中之重。回望Android的打包过程中,APK签名是相对最安全,无法被伪造破解的一个私钥,因此我们把目光放到了它身上。毫无疑问的,Android系统也给应用内部提供了获取签名的方式:
public static String getApkSignature(Context context) {
try {
// 获取apk签名做密钥
PackageInfo packageInfo = context.getPackageManager().getPackageInfo(
context.getPackageName(), PackageManager.GET_SIGNATURES);
String password = Base64.encodeToString(packageInfo.signatures[0].toByteArray(),
Base64.DEFAULT);
return password;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
return null;
}
}
为了可读性考虑,这里的签名取出后使用Base64进行编码
AES加解密
AES的英文为Advanced Encryption Standard,高级加密标准,是美国政府采用的一种加密标准。在密码学当中又称作为RIJNDAEL算法,这是一种密码长度与数据块长度都可以变化的分组分组加密算法,这个标准由于安全、性能好、效率高、实用、灵活而被用来替代原先的DES,已经被多方分析且广为全世界所使用。AES是现代的对称加密算法,通常的实现是使用128位加密。
在Java标准库中,也已经给出了AES的算法实现。方便使用,本文对AES做了一下简单封装:
public class AES {
private static final String IV = "CUSTOMGIVEDAPPIV";
/**
* Encrypt string to string with password
*
* @param rawData
* @param password
* @return
*/
public static final String encrypt(String rawData, String password) {
try {
SecretKeySpec key = generateKey(password);
byte[] result = doEncrypt(key, rawData.getBytes());
return Base64.encodeToString(result, Base64.DEFAULT);
} catch (Exception e) {
return rawData;
}
}
public static final String decrypt(String cipherData, String password) {
try {
SecretKeySpec key = generateKey(password);
byte[] enc = Base64.decode(cipherData, Base64.DEFAULT);
byte[] result = doDecrypt(key, enc);
return new String(result);
} catch (Exception e) {
Log.i("xxx", e.getMessage());
return cipherData;
}
}
/**
* @param password
* @return
* @throws Exception
*/
private static SecretKeySpec generateKey(String password) throws Exception {
byte[] data = null;
if (password == null) {
password = "";
}
StringBuffer sb = new StringBuffer(16);
sb.append(password);
while (sb.length() < 16) {
sb.append("\0");
}
if (sb.length() > 16) {
sb.setLength(16);
}
try {
data = sb.toString().getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return new SecretKeySpec(data, "AES");
}
/**
* AES 加密
*
* @param key AES 加密的KEY
* @param clear AES 加密的内容 (128位的明文)
* @return 返回 128位的密文
* @throws Exception
*/
private static byte[] doEncrypt(SecretKeySpec key, byte[] clear) throws Exception {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
IvParameterSpec ivspec = new IvParameterSpec(IV.getBytes());
cipher.init(Cipher.ENCRYPT_MODE, key, ivspec);
return cipher.doFinal(clear);
}
/**
* AES 解密
*
* @param key AES 解密的KEY
* @param encrypted AES 解密的内容 (128位的密文)
* @return 返回 (128位的明文)
* @throws Exception
*/
private static byte[] doDecrypt(SecretKeySpec key, byte[] encrypted) throws Exception {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
IvParameterSpec ivspec = new IvParameterSpec(IV.getBytes());
cipher.init(Cipher.DECRYPT_MODE, key, ivspec);
return cipher.doFinal(encrypted);
}
}
代码中的IV指的是偏移常量,最少需要16个字节的长度,你们可以根据喜好自行定义它的值
apikey,apisecret和token
在Restful后台服务中,通常会设计apikey,apisecret和token这三个值,他们在用户身份验证中扮演着不同的作用:
- apikey: 颁发给应用或者机构的密钥,用于标明应用或者机构的身份,相当于用户名,保密等级低
- apisecret:颁发给客户端的私钥,用于对请求进行签名,服务端可以对签名验证其合法性,保密等级高
- token:通常指Access token,即用户私钥,在每个用户登录之后自动生成,用于验证登录用户的身份,存在过期时间,保密等级高
加密apikey和apisecret
为了安全性考虑,我们不能直接将apikey和apisecret写在客户端中,这样的常量字符串极有可能被反编译破解。利用上面提到的APK签名和AES加解密算法,很容易想到可以使用他们对密钥进行加密,从而在代码中只存储加密后的apikey和apisecret。当应用启动后,读取APK签名并解密密钥。代码如下:
// 获取加密结果(打包前操作)
public static void get(Context context) {
try {
// 获取apk签名做密钥
PackageInfo packageInfo = context.getPackageManager().getPackageInfo(
context.getPackageName(), PackageManager.GET_SIGNATURES);
String password = Base64.encodeToString(packageInfo.signatures[0].toByteArray(),
Base64.DEFAULT);
// 用签名解密出api key
Log.d(TAG, "Encrypted apikey=" + AES.encrypt("thisisapikey", password));
// G6t3IHRvM6GQ28RZ3RFojQ==
Log.d(TAG, "Encrypted apisecret=" + AES.encrypt("thisisapisecret", password));
// vtZn+Kmt+wK4zy9xSjrlqg==
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
}
// 解密密钥(启动后操作)
public static final String ENCRYPTED_API_KEY = "G6t3IHRvM6GQ28RZ3RFojQ==";
public static final String ENCRYPTED_API_SECRET = "vtZn+Kmt+wK4zy9xSjrlqg==";
private void initKey(Context context) {
PackageInfo packageInfo = null;
try {
// 获取apk签名做密钥
packageInfo = context.getPackageManager().getPackageInfo(
context.getPackageName(), PackageManager.GET_SIGNATURES);
String password = Base64.encodeToString(packageInfo.signatures[0].toByteArray(),
Base64.DEFAULT);
// 用签名解密出api key
mApiKey = AES.decrypt(ENCRYPTED_API_KEY, password);
mApiSecret = AES.decrypt(ENCRYPTED_API_SECRET, password);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
}
一重验证:apikey + token验证
在请求中添加apikey和Access token,服务端对二者做简单验证。其中,
apikey:请求中apikey的添加没有硬性规定,不过一般放在query参数中即可。
token:OAuth标准规范中,通常是在请求Header中加入Authorization字段,值为Bearer xxx。
二重验证:校验请求签名合法性
如果apikey和token遭遇泄露,上面的验证方式将不再是安全的。因此很有必要添加第二重验证,需要注意的是,这里的验证方式不是唯一的,只需客户端和服务端协商好即可。抛玉引转,先简单介绍一种校验方式:
简而言之,就是在请求参数里加上签名摘要参数,这个签名由以下部分构成:请求方式+资源路径+token+时间戳,即{GET|POST}&{PATH}&{TOKEN}&{TIMESTAMP}。拼接得到的结果再使用apisecret私钥进行hmac哈希计算,得到摘要字符串,连同时间戳附带在请求参数里。后端收到请求后做同样的运算,如果摘要相同说明这个请求是合法的,缺少摘要或者不符则为非法请求。上述算法的客户端实现如下:
/**
* 计算签名信息
*
* @param request
* @return
*/
private Pair<String, String> getRequestSignature(Request request) {
if (null == request) {
return null;
}
String apiSecret = Api.get().apiSecret();
if (TextUtils.isEmpty(apiSecret)) {
return null;
}
final StringBuilder s = new StringBuilder();
s.append(request.method());
String path = request.url().encodedPath();
if (path == null) {
return null;
}
path = Uri.decode(path);
if (path == null) {
return null;
}
if (path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}
s.append("&").append(Uri.encode(path));
// 获取token
String accessToken = request.header(Http.HEADER_AUTHORIZATION);
if (!TextUtils.isEmpty(accessToken)) {
accessToken = accessToken.substring(OAUTH_PREFIX.length());
}
if (!TextUtils.isEmpty(accessToken)) {
s.append("&").append(accessToken);
}
final long timestamp = System.currentTimeMillis() / 1000;
s.append("&").append(timestamp);
String baseString = s.toString();
String signature = HMACHash1.encode(apiSecret, baseString);
return new Pair<>(signature, String.valueOf(timestamp));
}
示例项目
详细的示例代码可以在 ApiEncryptDemo 中找到,项目使用OkHttp进行网络通信,基于OkHttp的拦截器实现签名参数的添加。