讲讲网络模块中加解密那点儿事--AES+BASE64

这次想来讲讲网络安全通信这一块,也就是网络层封装的那一套加密、解密,编码、解码的规则,不会很深入,但会大概将这一整块的讲一讲。

之所以想写这篇,是因为,最近被抽过去帮忙做一个 C++ 项目,在 Android 中,各种编解码、加解密算法官方都已经封装好了,我们要使用非常的方便,但在 C++ 项目中很多都要自己写。

然而,自己写是不可能的了,没这么牛逼也没这么多时间去研究这些算法,网上自然不缺少别人写好的现成算法。但不同项目应用场景自然不一样,一般来说,都需要对其进行修修改改才能拿到项目中来用。

踩的坑实在有点儿多,所以想写一篇来总结一下。好了,废话结束,开始正文。

提问

Q1: 你的 app 与后台各接口通信时有做身份校验吗?

Q2: 你的 app 与后台各接口通信的数据有涉及敏感数据吗?你是如何处理的?

Q3: MD5 了解过吗?

Q4: AES(16位密钥 + CBC + PKCS5Padding) 呢?

Q5: BASE64 呢?或者 UTF-8?

理论

身份校验 -- MD5 算法

第一点:为什么需要身份校验?

身份校验是做什么,其实也就是校验访问接口的用户合法性。说得白一点,也就是要过滤掉那些通过脚本或其他非正常 app 发起的访问请求。

试想一下,如果有人破解了服务端某个接口,然后写个脚本,模拟接口所需的各种参数,这样它就可以伪装成正常用户从这个接口拿到他想要的数据了。

更严重点的是,如果他想图摸不轨,向服务端发送了一堆伪造的数据,如果这些数据会对服务端造成损失怎么办。

所以,基本上服务端的接口都会有身份校验机制,来检测访问的对象是否合法。

第二点:MD5 算法是什么?

通俗的讲,MD5 算法能对一串输入生成一串唯一的不可逆的 128 bit 的 0 和 1 的二进制串信息。

通常 app 都会在发起请求前根据自己公司所定义的规则做一次 MD5 计算,作为 token 发送给服务端进行校验。

MD5 有两个特性:唯一性和不可逆性。

唯一性可以达到防止输入被篡改的目的,因为一旦第三方攻击者劫持了这个请求,篡改了携带的参数,那么服务端只要再次对这些输入做一次 MD5 运算,比较计算的结果与 app 上传的 token 即可检测出输入是否有被修改。

不可逆的特点,则是就算第三方攻击者劫持了这次请求,看到了携带的参数,以及 MD5 计算后的 token,那么他也无法从这串 token 反推出我们计算 MD5 的规则,自然也就无法伪造新的 token,那么也就无法通过服务端的校验了。

第三点:理解 16 位和 32 位 MD5 值的区别

网上有很多在线进行 MD5 计算的工具,如 http://www.cmd5.com/,这里演示一下,尝试一下分别输入:

I am dasuI'm dasu 看一下经过 MD5 运算后的结果:

MD5.png

MD5_.png

首先确认一点,不同的输入,输出就会不一样,即使只做了细微修改,两者输出仍旧毫无规律而言。

另外,因为经过 MD5 计算后输出是 128 bit 的 0 和 1 二进制串,但通常都是用十六进制来表示比较友好,1个十六进制是 4 个 bit,128 / 4 = 32,所以常说的 32 位的 MD5 指的是用十六进制来表示的输出串。

那么,为什么还会有 16 位的 MD5 值?其实也就是嫌 32 位的数据太长了,所以去掉开头 8 位,末尾 8 位,截取中间的 16 位来作为 MD5 的输出值。

所以,MD5 算法的输出只有一种:128 bit 的二进制串,而通常结果都用十六进制表示而已,32 位与 16 位的只是精度的区别而已。

第四点:MD5 的应用

应用场景很多:数字签名、身份校验、完整性(一致性)校验等等。

这里来讲讲 app 和服务端接口访问通过 MD5 来达到身份校验的场景。

app 持有一串密钥,这串密钥服务端也持有,除此外别人都不知道,因此 app 就可以跟服务端协商,两边统一下交互的时候都有哪些数据是需要加入 MD5 计算的,以怎样的规则拼接进行 MD5 运算的,这样一旦这些数据被三方攻击者篡改了,也能检查出来。

也就是说,密钥和拼接规则都是关键点,不可以泄漏出去。

敏感数据加密 -- AES + BASE64

MD5 只能达到校验的目的,而 app 与服务端交互时,数据都是在网络中传输的,这些请求如果被三方劫持了,那么如果交互的数据里有一些敏感信息,就会遭到泄漏,存在安全问题。

当然,如果你的 app 与服务端的交互都是 HTTPS 协议了的话,那么自然就是安全的,别人抓不到包,也看不到信息。

如果还是基于 HTTP 协议的话,那么有很多工具都可以劫持到这个 HTTP 包,app 与服务端交互的信息就这样赤裸裸的展示在别人面前。

所以,通常一些敏感信息都会经过加密后再发送,接收方拿到数据后再进行解密即可。

而加解密的世界很复杂,对称加密、非对称加密,每一种类型的加解密算法又有很多种,不展开了,因为实在展开不了,我门槛都没踏进去,实在没去深入学习过,目前只大概知道个流程原理,会用的程度。

那么,本篇就介绍一种网上很常见的一整套加解密、编解码流程:

UTF-8 + AES + BASE64

UTF-8 和 BASE64 都属于编解码,AES 属于对称加密算法。

信息其实本质上是由二进制串组成,通过各种不同的编码格式,来将这段二进制串信息解析成具体的数据。比如 ASCII 编码定义了一套标准的英文、常见符号、数字的编码;UTF-8 则是支持中文的编码。目前大部分的 app 所使用的数据都是基于 UTF-8 格式的编码的吧。

AES 属于对称加密算法,对称的意思是说,加密方和解密方用的是同一串密钥。信息经过加密后会变成一串毫无规律的二进制串,此时再选择一种编码方式来展示,通常是 BASE64 格式的编码。

BASE64 编码是将所有信息都编码成只用大小写字母、0-9数字以及 + 和 / 64个字符表示,所有称作 BASE64。

不同的编码所应用的场景不同,比如 UTF-8 倾向于在终端上呈现各种复杂字符包括简体、繁体中文、日文、韩文等等数据时所使用的一种编码格式。而 BASE64 编码通常用于在网络中传输较长的信息时所使用的一种编码格式。

基于以上种种,目前较为常见的 app 与服务端交互的一套加解密、编解码流程就是:UTF-8 + AES + BASE64

加解密流程.png

上图就是从 app 端发数据给服务端的一个加解密、编解码过程。

需要注意的是,因为 AES 加解密时输入和输出都是二进制串的信息,因此,在发送时需先将明文通过 UTF-8 解码成二进制串,然后进行加密,再对这串二进制密文通过 BASE64 编码成密文串发送给接收方。

接收方的流程就是反着来一遍就对了。

代码

理论上基本清楚了,那么接下去就是代码实现了,Android 项目中要实现很简单,因为 JDK 和 SDK 中都已经将这些算法封装好了,直接调用 api 接口就可以了。

Java

public class EncryptDecryptUtils {
    private static final String ENCODE = "UTF-8";
    //AES算法加解密模式有多种,这里选择 CBC + PKCS5Padding 模式,CBC 需要一个AES_IV偏移量参数,而AES_KEY 是密钥。当然,这里都是随便写的,这些信息很关键,不宜泄露
    private static final String AES = "AES";
    private static final String AES_IV = "aaaaaaaaaaaaaaaa";
    private static final String AES_KEY = "1111111111111111";//16字节,128bit,三种密钥长度中的一种
    private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";

    /**
    * AES加密后再Base64编码,输出密文。注意AES加密的输入是二进制串,所以需要先将UTF-8明文转成二进制串
    */
    public static String doEncryptEncode(String content) throws Exception {
        SecretKeySpec secretKeySpec = new SecretKeySpec(AES_KEY.getBytes(ENCODE), AES);
        Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
        cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, new IvParameterSpec(AES_IV.getBytes(ENCODE)));
        //1. 先获取二进制串,再进行AES(CBC+PKCS5Padding)模式加密
        byte[] result = cipher.doFinal(content.getBytes(ENCODE));
        //2. 将二进制串编码成BASE64串
        return Base64.encodeToString(result, Base64.NO_WRAP);
    }

    /**
    * Base64解码后再进行AES解密,最后对二进制明文串进行UTF-8编码输出明文串
    */
    public static String doDecodeDecrypt(String content) throws Exception {
        SecretKeySpec secretKeySpec = new SecretKeySpec(AES_KEY.getBytes(ENCODE), AES);
        Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
        cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, new IvParameterSpec(AES_IV.getBytes(ENCODE)));
        //1. 先将BASE64密文串解码成二进制串
        byte[] base64 = Base64.decode(content, Base64.NO_WRAP);
        //2. 再将二进制密文串进行AES(CBC+PKCS5Padding)模式解密
        byte[] result = cipher.doFinal(base64);
        //3. 最后将二进制的明文串以UTF-8格式编码成字符串后输出
        return new String(result, Charset.forName(ENCODE)); 
    }
}

Java 的实现代码是不是很简单,具体算法的实现都已经封装好了,就是调一调 api 的事。

这里需要稍微知道下,AES 加解密模式分很多种,首先,它有三种密钥形式,分别是 128 bit,192 bit,256 bit,注意是 bit,Java 中的字符串每一位是 1B = 8 bit,所以上面例子中密钥长度是 16 位的字符串。

除了密钥外,AES 还分四种模式的加解密算法:ECB,CBC,CFB,OFB,这涉及到具体算法,我也不懂,就不介绍了,清楚上面是使用了 CBC 模式就可以了。

最后一点,使用 CBC 模式进行加密时,是对明文串进行分组加密的,每组的大小都一样,因此在分组时就有可能会存在最后一组的数量不够的情况,那么这时就需要进行填充,而这个填充的概念就是 PKCS5Padding 和 PKCS7Padding 两种。

这两种的填充规则都一样,具体可看其他的文章,区别只在于分组时规定的每组的大小。在PKCS5Padding中,明确定义 Block 的大小是 8 位,而在 PKCS7Padding 定义中,对于块的大小是不确定的,可以在 1-255 之间。

稍微了解下这些就够了,如果你不继续往下研究 C++ 的写法,这些不了解也没事,会用就行。

C++

c++ 坑爹的地方就在于,这整个流程,包括 UTF-8 编解码、AES 加解密、BASE64 编解码都得自己写。

当然,不可能自己写了,网上轮子那么多了,但问题就在于,因为 AES 加解密模式太多了,网上的资料大部分都只是针对其中一种进行介绍,因此,如果不稍微了解一下相关原理的话,就无从下手进行修改了。

我这篇,自然也只是介绍我所使用的模式,如果你刚好跟我一样,那也许可以帮到你,如果跟你不一样,至少我列出了资料的来源,整篇下来也稍微讲了一些基础性的原理,掌握这些,做点儿修修补补应该是可以的。

贴代码前,先将我所使用的模式列出来:

UTF-8 + AES(16位密钥 + CBC + PKCS5Padding) + BASE64

其实这些都类似于工具类,官方库没提供,那网上找个轮子就好了,都是一个 h 和 cpp 文件而已,复制粘贴下就可以了。重点在于准备好了这些工具类后,怎么用,怎么稍微修改。

如果你不想自己网上找,那下面我已经将相关链接都贴出来了,去复制粘贴下就可以了。

c++ string、UTF8相互转换方法

C++使用AES+Base64算法对文本进行加密

我最开始就是拿的第二篇来用的,然后才发现他所采用的模式是:AES(16位密钥 + CBC + PKCS7Padding) + BASE64

也就是说,他的例子中不支持中文的加解密,而且填充模式采用的是 PKCS7Padding,跟我的不一致。一开始我也不了解相关原理基础,怎么调都调不出结果,无奈只能先去学习下原理基础。

还好后面慢慢的理解了,也懂得该改哪些地方,也增加了 UTF-8 编解码的处理。下面贴的代码中注释会写得很清楚,整篇看下来,我相信,就算你模式跟我的也不一样,你的密钥是24位的、32位的,没关系,稍微改一改就可以了。

//EncryptDecryptUtils.h
#pragma once
#include <string>

using namespace std;

#ifndef AES_INFO
#define AES_INFO

#define AES_KEY "1111111111111111"  //AES 16B的密钥
#define AES_IV "aaaaaaaaaaaaaaaa" //AES CBC加解密模式所需的偏移量

#endif 

class EncryptDecryptUtils {
public:
    //解码解密
    static string doDecodeDecrypt(string content);
    //加密编码
    static string doEncryptEncode(string content);
    EncryptDecryptUtils();
    ~EncryptDecryptUtils();
private:
    //去除字符串中的空格、换行符
    static string removeSpace(string content);
};

以下才是具体实现,其中在头部 include 的 AES.h,Base64.h,UTF8.h 需要先从上面给的博客链接中将相关代码复制粘贴过来。这些文件基本都是作为工具类使用,不需要进行改动。可能需要稍微改一改的就只是 AES.h 文件,因为不同的填充模式需要改一个常量值。

//EncryptDecryptUtils.cpp
#include "EncryptDecryptUtils.h"
#include "AES.h"
#include "Base64.h"
#include "UTF8.h"

EncryptDecryptUtils::EncryptDecryptUtils()
{
}
~EncryptDecryptUtils::EncryptDecryptUtils()
{
}

/**
* 流程:服务端下发的BASE64编码的密文字符串 -> 去除字符串中的换行符 -> BASE64解码 -> AES::CBC模式解密 -> 去掉AES::PKCS5Padding 填充 -> UTF-8编码 -> 明文字符串
*/
string EncryptDecryptUtils::doDecodeDecrypt(string content)
{   
    //1.去掉字符串中的\r\n换行符
    string noWrapContent = removeSpace(string);
    //2. Base64解码
    string strData = base64_decode(noWrapContent);
    size_t length = strData.length();

    //3. new些数组,给解密用
    char *szDataIn = new char[length + 1];
    memcpy(szDataIn, strData.c_str(), length + 1);
    char *szDataOut = new char[length + 1];
    memcpy(szDataOut, strData.c_str(), length + 1);

    //4. 进行AES的CBC模式解密
    AES aes;
    //在这里传入密钥,和偏移量,以及指定密钥长度和iv长度,如果你的密钥长度不是16字节128bit,那么需要在这里传入相对应的参数。
    aes.MakeKey(string(AES_KEY).c_str(), string(AES_IV).c_str(), 16, 16);
    //这里参数有传入指定加解密的模式,AES::CBC,如果你不是这个模式,需要传入相对应的模式,源码中都有注释说明
    aes.Decrypt(szDataIn, szDataOut, length, AES::CBC);

    //5.去PKCS5Padding填充:解密后需要将字符串中填充的去掉,根据填充规则进行去除,感兴趣可去搜索相关的填充规则
    if (0x00 < szDataOut[length - 1] <= 0x16)
    {
        int tmp = szDataOut[length - 1];
        for (int i = length - 1; i >= length - tmp; i--)
        {
            if (szDataOut[i] != tmp)
            {
                memset(szDataOut, 0, length);
                break;
            }
            else
                szDataOut[i] = 0;
        }
    }

    //6. 将二进制的明文串转成UTF-8格式的编码方式,输出
    string srcDest = UTF8_To_string(szDataOut);
    delete[] szDataIn;
    delete[] szDataOut;
    return srcDest;
}

/**
* 流程:UTF-8格式的明文字符串 -> UTF-8解码成二进制串 -> AES::PKCS5Padding 填充 -> AES::CBC模式加密 -> BASE64编码 -> 密文字符串
*/
string EncryptDecryptUtils::doEncryptEncode(string content)
{
    //1. 先获取UTF-8解码后的二进制串
    string utf8Content = string_To_UTF8(content);
    size_t length = utf8Content.length();
    int block_num = length / BLOCK_SIZE + 1;
    
    //2. new 些数组供加解密使用
    char* szDataIn = new char[block_num * BLOCK_SIZE + 1];
    memset(szDataIn, 0x00, block_num * BLOCK_SIZE + 1);
    strcpy(szDataIn, utf8Content.c_str());

    //3. 进行PKCS5Padding填充:进行CBC模式加密前,需要填充明文串,确保可以分组后各组都有相同的大小。
    // BLOCK_SIZE是在AES.h中定义的常量,PKCS5Padding 和 PKCS7Padding 的区别就是这个 BLOCK_SIZE 的大小,我用的PKCS5Padding,所以定义成 8。如果你是使用 PKCS7Padding,那么就根据你服务端具体大小是在 1-255中的哪个值修改即可。
    int k = length % BLOCK_SIZE;
    int j = length / BLOCK_SIZE;
    int padding = BLOCK_SIZE - k;
    for (int i = 0; i < padding; i++)
    {
        szDataIn[j * BLOCK_SIZE + k + i] = padding;
    }
    szDataIn[block_num * BLOCK_SIZE] = '\0';

    char *szDataOut = new char[block_num * BLOCK_SIZE + 1];
    memset(szDataOut, 0, block_num * BLOCK_SIZE + 1);

    //4. 进行AES的CBC模式加密
    AES aes;
     //在这里传入密钥,和偏移量,以及指定密钥长度和iv长度,如果你的密钥长度不是16字节128bit,那么需要在这里传入相对应的参数。
    aes.MakeKey(string(AES_KEY).c_str(), string(AES_IV).c_str(), 16, 16);
    //这里参数有传入指定加解密的模式,AES::CBC,如果你不是这个模式,需要传入相对应的模式,源码中都有注释说明
    aes.Encrypt(szDataIn, szDataOut, block_num * BLOCK_SIZE, AES::CBC);
    
    //5. Base64编码
    string str = base64_encode((unsigned char*)szDataOut, block_num * BLOCK_SIZE);
    delete[] szDataIn;
    delete[] szDataOut;
    return str;
}

//去除字符串中的空格、换行符
string EncryptDecryptUtils::formatText(string src)
{
    int len = src.length();
    char *dst = new char[len + 1];
    int i = -1, j = 0;
    while (src[++i])
    {
        switch (src[i])
        {
        case '\n':
        case '\t':
        case '\r':
            continue;
        }
        dst[j++] = src[i];
    }
    dst[j] = '\0';
    string rel = string(dst);
    delete dst;
    return rel;
}

再列个在线验证 AES 加解密结果的网站,方便调试:

http://www.seacha.com/tools/aes.html

Java 实现那么方便,为什么还需要用 C++ 的呢?

想一想,密钥信息那么重要,你要放在哪?像我例子那样直接写在代码中?那只是个例子,别忘了,app 混淆的时候,字符串都是不会参与混淆的,随便反编译下你的 app,密钥就暴露给别人了。

那么,有其他比较好的方式吗?我只能想到,AES 加解密相关的用 C++ 来写,生成个 so 库,提供个 jni 接口给 app 层调用,这样密钥信息就可以保存在 C++ 中了。

也许你会觉得,哪有人那么闲去反编译 app,而且正在写的 app 又没有什么价值让别人反编译。

emmm,说是这么说,但安全意识还是要有的,至少也要先知道有这么个防护的方法,以及该怎么做,万一哪天你写的 app 就火了呢?


大家好,我是 dasu,欢迎关注我的公众号(dasuAndroidTv),如果你觉得本篇内容有帮助到你,可以转载但记得要关注,要标明原文哦,谢谢支持~
dasuAndroidTv2.png

posted @ 2018-08-04 20:50 请叫我大苏 阅读(...) 评论(...) 编辑 收藏