一种移动应用Http请求的加密方法——以Android为例

1,455 阅读6分钟
原文链接: yimu.me

现阶段移动应用主要通过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的拦截器实现签名参数的添加。

参考文章