**Q1: 你的 app 與背景各接口通信時有做身份校驗嗎?**
**Q2: 你的 app 與背景各接口通信的資料有涉及敏感資料嗎?你是如何處理的?**
**Q3: MD5 了解過嗎?**
**Q4: AES(16位密鑰 + CBC + PKCS5Padding) 呢?**
**Q5: BASE64 呢?或者 UTF-8?**
本篇文章已授權微信公衆号 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 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互相轉換方法
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),如果你覺得本篇内容有幫助到你,可以轉載但記得要關注,要标明原文哦,謝謝支援~