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


    本篇文章已授权微信公众号 dasuAndroidTv(大苏)独家发布

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

    之所以想写这篇,是因为,最近被抽过去帮忙做一个 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.去掉字符串中的
    换行符
     	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] = '';
    
    	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 '
    ':
    		case '	':
    		case '
    ':
    			continue;
    		}
    		dst[j++] = src[i];
    	}
    	dst[j] = '';
    	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

  • 相关阅读:
    Inno Setup入门(一)——最简单的安装脚本
    inno setup基础使用教程
    Inno Setup入门(八)——有选择性的安装文件
    Inno Setup入门(三)——指定压缩方式
    Inno Setup入门(七)——提供安装语言选项
    Inno Setup 详解中文资料
    Inno Setup入门(五)——添加readme文件
    Inno Setup 替换安装程序和卸载程序的图标
    用Inno Setup来解决.NetFramework安装问题
    写个类操作窗口(句柄操作)
  • 原文地址:https://www.cnblogs.com/dasusu/p/9419877.html
Copyright © 2020-2023  润新知