这次想来讲讲网络安全通信这一块, 也就是网络层封装的那一套加密, 解密, 编码, 解码的规则, 不会很深入, 但会大概将这一整块的讲一讲.
之所以想写这篇, 是因为, 最近被抽过去帮忙做一个 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 dasu 和 I'm dasu 看一下经过 MD5 运算后的结果:
首先确认一点, 不同的输入, 输出就会不一样, 即使只做了细微修改, 两者输出仍旧毫无规律而言.
另外, 因为经过 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
上图就是从 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 相互转换方法 https://blog.csdn.net/yinshi_blog/article/details/6731809
C++ 使用 AES+Base64 算法对文本进行加密 https://blog.csdn.net/csdn49532/article/details/50686222
我最开始就是拿的第二篇来用的, 然后才发现他所采用的模式是: 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 就火了呢?
来源: https://www.cnblogs.com/dasusu/p/9419877.html