常用加密算法分析和實現

一、前言

工作中有時候需要對數據進行加密,就筆者從事的Android開發來說, 上層開發語言爲Java/Kotlin,JDK本身提供了一套加解密接口,可以很方便地調用加密/解密方法。
但有時候需要在native層實現加密邏輯的封裝,這時候只能找C/C++的實現了。

當前的加密算法可以大約分爲三類:

  • 散列算法:MD5,SHA家族等;
  • 對稱加密:AES,DES等;
  • 非對稱加密:RSA,DSA,DH等。

MD5/SHA/AES等實現比較簡單,也很好找到,但是對於RSA就沒那麼好找了。
之前筆者就遇到需要在native層實現RSA場景。
去github上找代碼,很多都是簡單的demo演示(用long實現,幾十bits的密碼長度);
有的實現了大數,但是效率很低;
而openssl等加密庫,則是牽涉很多文件,想抽取其中的實現,卻無從下手。
最後我想了個workaround的辦法:通過在Java的層封裝Util類來調用JDK的RSA實現,然後在native層調用Util類的方法。

再後來遇到的場景是,各個端需要用C++統一部分業務邏輯的實現。
不巧的是,這部分邏輯也用到了RSA。
這回就不能反射調用Java來實現了,因爲IOS和PC的平臺不依賴JVM。
最終同事找了個C++的加密庫,雖然該加密庫可讀性不好,文件很多,但總歸依賴比openssl要少。

在那之後,我開始在關注常用的加密方法的實現,先後收集了AES、SHA、ECC等加密方法的比較好的C語言實現(執行快,依賴少)。
而RSA也通過參考JDK的實現,以及查閱相關資料,實現了C語言的版本。
最終,完成了包含AES-CBC, SHA256, HMAC-SHA25, RSA, ECDH, ECDSA等加密方法的收集。
代碼已上傳Github, 有需要的朋友可自行獲取。
項目地址:https://github.com/BillyWei01/EasyCipher

在收集的過程中查閱了一些資料,梳理了一些知識點,這裏和大家分享一下。

二、散列算法

散列函數(也叫哈希函數)的功能是把任意長度的輸入,通過哈散列算法,變換成固定長度的輸出(哈希值)。
這種轉換是一種壓縮映射,也就是,哈希值的空間通常遠小於輸入的空間,不同的輸入可能會散列成相同的輸出。
有的說法是哈散列算法不算加密算法,因爲通過哈希值無法還原唯一的原始輸入。
嚴格來說確實如此,但是不能否認的是,哈希算法確實是用在了許多確保數據安全的地方,所以從這個角度來說,將其歸類爲加密算法也沒太大問題。

並非所有的哈希函數都適用於加密,比如普通的用於計算哈希表索引的函數就不適用。
用於加密的哈希函數通常需要具備一下特性:

  • 抗原像攻擊性: 對於隨意給定的h,找到滿足H(x)=h的x在計算上不可行。
  • 抗弱碰撞性: 對於隨意給定的數據塊x,找到滿足H(y)=H(x)的y ≠ x在計算上不可行。
  • 強抗碰撞性:找到滿足H(x) = H(y)的隨意一對(x,y)在計算上不可行。

比方說JDK計算字符串hash的函數:

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

這個函數很顯然就不具備抗原像攻擊性,比方說當字符串爲”A“,其哈希值爲65,也就是字符‘A’的ASCII碼值。

而像MurmurHash這樣的哈希函數,有比較好的抗原像攻擊性,但不具備抗碰撞性。
當然,大家一般也不會用這類函數來做“加密”,而僅僅是用來計算哈希表索引。
但僅用來計算索引這樣的情況,也會因爲不具備抗碰撞性而攻擊,比如《 由MurmurHash2算法碰撞引起的Redis DDos攻擊漏洞》這篇文章所述,攻擊者可以計算大量的具備相同哈希值的輸入,造成redis哈希表的性能退化,從而使其無法正常提供服務。
爲了解決這類攻擊,JDK 1.8的方案是當hash衝突過多時,將鏈表轉換爲平衡樹,從而避免性能退化。
而redis的解決方案則是換一個哈希算法:SipHash
SipHash能夠有效減緩hash flooding攻擊。憑藉這一點,它逐漸成爲 Ruby、Python、Rust 等語言默認的哈希表實現的一部分。

能做到減緩碰撞哈希值的構造,已經普通哈希算法是其極限了。
要做“加密”,還是需要強度足夠的哈希算法。
MD5是廣泛使用的加密哈希,但在2004被“破解”(在一定時間內找到碰撞,並非找到原始輸入);
而SHA-1目前也是被“破解”了,很多瀏覽器已經不支持包含SHA-1的加密套件了。
目前廣泛使用的加密哈希爲SHA-2家族,其中用的最多的是SHA-256。

雖然加密哈希具備較高強度的抗原像攻擊性和抗碰撞性,但是僅靠其特性並不能確保數據安全。

由於輸入x,會得到確定的輸入y, 所以對於一些較短的輸入,可能被暴力破解,或者字典攻擊
如果是登錄系統,對應的策略可以是:

  • 提高複雜度,比方說很多地方需要輸入包含“大小寫字母和字符”之類的密碼;
  • 限制登錄次數;
  • 用比較耗時的哈希函數(如bcrypt)等。

暴力破解不單單針對哈希加密,對稱加密同樣有可能遭受暴力破解。
有一類攻擊是僅針對哈希算法的:彩虹表攻擊
對此類攻擊的對應方法之一是“加鹽”。
網站記錄用戶密碼最好是記錄加鹽的哈希,萬一不幸被拖庫了也不至於泄漏用戶密碼。
多年前有一些網站被曝泄漏用戶的賬號密碼,甚至有的網站保存的是用戶的明文密碼!有的用戶可能多個網站用同一個密碼,在一個網站泄漏了明文密碼,會波及其他網站註冊的賬號。
好在涉及交易和支付的系統都會設計二次認證,否則影響會更嚴重。

加密哈希有一種使用場景:消息認證碼 (Message Authentication Code,簡稱MAC)。
MAC使用不當的話也會遭受攻擊,比如長度擴展攻擊
業界常用的MAC算法是HMAC。

HMAC過程示意圖如下:

i_pad是重複0x36,o_pad是重複0x5C, 而SHA1的塊大小爲64字節(MD5和SHA256也是64字節),所以是重複64次。
如果key長度大於64字節,則計算哈希值作爲key。
簡單概括,HMAC就是計算 H(key XOR o_pad, H(key XOR i_pad, message))
在key小於塊大小時,只需要計算兩次哈希,速度很快。
像RSA結合SHA也可以對消息“簽名”,但是RSA運算比較耗時;
所以在加密通信中通常用非對稱加密來協商密鑰,後續會話用協商的密鑰來做對稱加密和HMAC運算。

三、對稱加密

對稱加密有很多種實現,這裏只講一下目前使用比較廣泛的是AES算法。
AES是一種塊加密算法,塊大小爲16字節。
其核心部分(塊加密的部分),輸入和輸出都是16字節,換種說法就是,函數的輸入和輸出是一一映射的。
相對的,哈希運算的輸入空間大小不限,輸出空間是固定長度(所以僅憑hash值無法計算唯一的輸入值)。

AES的塊加密部分,需要經過多輪運算,密鑰長度(可以是128, 192, 256比特)越長,執行的輪數越多;
每輪運算有四個子運算:字節替代、行移位、列混淆、 輪密鑰加。

  • 字節替代(SubBytes)
    通過查表(S盒)的方式,以字節爲單位,用另一個字節替換當前字節。
    這有點類似於代碼混淆,卻別在於代碼混淆是用另外一個字符串替換當前的字符串。
    經過混淆後,原文本來具備可讀性變得不可讀了;
    但是混淆後模式沒有改變,比如原來方法foo()混淆後變爲a(), 則混淆後所有調用foo()的地方都是a()。
    也就是,字節替代後仍然存在統計特徵。

  • 行位移(ShiftRows)
    行位移比較簡單,就是將16字節作爲一個4行4列的矩陣,
    其中1、2、3行分別左移(逆運算爲右移)1、2、3個字節。

  • 列混淆(MixColumns)
    和行位移一樣,將16字節劃分爲4行4列;
    不同的是,列混淆是分別對每一列的4個字節做運算(和一個4x4的矩陣左乘)。
    解密時也是左乘矩陣,解密矩陣是加密矩陣的逆矩陣。
    矩陣運算需要進行加法運算和乘法運算,計算機的直接整數相加和直接整數相乘可能會溢出,從而丟失信息,使得計算不可逆,所以AES將列混淆放到“有限域”中做運算。
    行位移和列混淆共同提供了算法的擴散性
    擴散的目的是讓明文中的單個數字影響密文中的多個數字,從而使明文的統計特徵在密文中消失。
    如果只有列混淆運算,則最終的效果是 [0,3] , [4,7], [8,11], [12,15] 四個分組分別擴展;
    加上了行位移,纔可以達到[0, 15]的字節的擴散效果(明文一個字節改變,密文16個字節全都會變化)。
  • 輪密鑰加(AddRoundKey)
    輪密鑰加是四個字運算中最簡單的,具體就是16個字節分別和輪密鑰做異或運算。
    輪密鑰是通過原始密鑰通過擴展密鑰計算得到,確保每一輪的密鑰都不相同。
    結合密鑰的運算,提供了算法的保密性。
    如果沒有密鑰參與運算,則前面的運算都只是“編碼”而已(類似於base64), 每個人都可以“解碼”,那就不是“加密”了。

當明文長度大約16字節是,就需“分組”了,每16字節一組;
明文長度不一定是16的倍數,所以最後一組需要“補齊(padding)”, 通常大家用的是“PKCS5Padding”或者“PKCS7Padding”,具體做法是:
最後一組缺n個字節湊夠16,則最後的n個字節都填n; 如果明文剛好是16字節的倍數,則在末尾增加16字節,並全部填充16。

關於“PKCS5Padding”和“PKCS7Padding”的區別:
嚴格來講PKCS7Padding是以塊大小來對齊,而PKCS5Padding則定義塊大小是8字節;
但是對於AES來說,塊大小固定爲16字節,所以其實兩者沒有區別。
IOS只支持PKCS7Padding,Oracle JDK只支持PKCS5Padding,而Android SDK兩者都支持,而且加密結果是一樣的。

對明文分組和補齊之後,還要選擇“模式“。
最簡單的ECB模式,每個塊獨立加密/解密:

ECB模式的缺點是:
1、相同的明文會得到相同密文;
2、明文中的重複內容會在密文中有所體現。
一般不建議用此模式。

另一個比較簡單且常用的是CBC模式:

CBC模式引入了隨機的初始向量(IV),並且以塊加密的結果作爲下一個加密塊的初始向量,從而使得每次加密的密文都不相同,相同的塊也不會得到相同的密文。
CBC模式克服了ECB模式的安全性問題,而且操作簡單,是比較常用的加密模式之一。

四、非對稱加密

非對稱加密常見的有RSA,DSA,DH等算法,不過他們所解決的問題有所不同。
DH算法解決密鑰協商的問題,DSA解決數據真實性和完整性的問題(簽名),
而RSA則本身能夠加密和解密,既可以用於密鑰協商,也可以用於簽名。

RSA算法是各類非對稱加密中原理比較清晰的。
其數據證明過程就不具體介紹了,這裏我們簡單講一些其計算過程。

下面是計算過程的代碼演示:
(只是過程演示,實際實現中,基本類型的精度無法滿足,需要用到大數運算)

void rsa_test() {
    i64 p = 13;
    i64 q = 17;
    i64 n = p * q;
    i64 r = (p - 1) * (q - 1);
    i64 e = 7;
    i64 d = inv(e, r);
   
    i64 x  = 100;
    i64 m = modPow(x, e, n);
    i64 x0 = modPow(m, d, n);
}

步驟講解:

  1. 選取兩個大素數p, q。
    選取大質數通常需要用到Miller-Rabin算法
  2. 計算n=pq,r=(p-1)(q-1)。
  3. 隨機選取正整數1<e<r,滿足gcd(e,r)=1, 也就是e和r互質。
    很多實現中, e會直接選取65537, 因爲65537是個質數,必然和r互質,
    而且65537的的二進制是0x10001,只有兩個‘1’,做加解密時相關的運算會比較快。
    e和n構成公鑰
  4. 計算d,滿足de≡1(mod r) 。
    上式用到了同餘表示,也就是de%r=1%r (de和1除以r的餘數相同);
    因爲1%r=1, 所以最終就是de%r = 1。
    這一步通常用擴展歐幾里得算法求取d。
    d和n構成私鑰
  5. 加密:m=x^e mod n (注:此處的“^”是指乘方,不是異或)。
  6. 解密:x0=m^d mod n

RSA算法加密和解密的形式是相同的,都是求取 m = a^b % n。
通常RSA的實現中e和d(指數)都不小,所以先計算乘方再取餘是不可行的,可以通過快速冪算法來求取m。
而快速冪的計算過程中需要頻繁地做除法,所以通常會用蒙哥馬利算法來計算m。

由加解密的形式我們可以得到兩點信息:

  1. 公鑰可以用來加密明文,也可以用來解密私鑰所加密的密文,反之亦然。
    值得注意的是,用openssl或者jdk等生成的密鑰對,公鑰的e通常是固定的65537, 隨意千萬不要圖公鑰計算快而作爲服務端的密鑰,把私鑰放客戶端。
    而且,私鑰文件中通常包含了有e,d,n(包含了公鑰和私鑰的計算要素)。

  2. 輸入x必須小於n。
    本質上RSA的加密也是一個“輸入到輸出的一一映射”。
    相比於AES,AES的輸入空間是16字節,而RSA的輸入空間是[0,n)。
    實際上,在RSA的標準實現中,合法的輸入空間要比n要少幾個字節。
    我們通常說的1024, 2048(bit)長度的密鑰,指的是n的長度。

RSA的加密塊長度等於密鑰長度,其格式爲: [ 0x0 | BT | PS | 0x0 | DATA ] 。

  • BT:塊類型(block type),私鑰加密時BT=01 ,公鑰加密時BT=02 。
  • PS:填充字符串(padding string),基於安全性的考慮,規定填充字符串不少於8字節。公鑰加密時填充非零隨機字節,如此,即使明文相同,每次加密後密文都不相同; 私鑰加密時填充0xFF,解密後可以檢查一下PS部分是否都是0xFF, 若不是則說明加密塊非法。
  • DATA:明文,所以明文長度不能超過“密鑰長度-11字節”。

關於RSA最後一點:RSA的密鑰格式。
RSA常見的密鑰格式有PKCS#1,PKCS#8,x509等。
比如,用openssl命令生成的密鑰文件就是PKCS#1格式的。

// 生成私鑰
openssl genrsa -out private_pkcs1.pem 2048
// 導出公鑰
openssl rsa -in private_pkcs1.pem -out public_pkcs1.pem -pubout -RSAPublicKey_out

用文本文件打開公鑰文件,可以看到如下格式的文本:

-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAxFEkH3FTCGFRtCnLydJES+ShgmVjY7w3KwQxw9IVW+4p4mLL4V/+
p/m8pnoEaelVKX8fDxoWcJQQ2APGobMJ32MZkpWkFurSj2M5HlxLlH8hJNPYTHoN
UNh2SFeUtM1GkH9jyJRKqKS0qkJl6jXJGRRcKklNlYchIUdC2i+zqXoZw1KOva85
ISpU5Od3oZEeOqXtrC/OSzcTHNc1EdpyqpUpGZpPoFUHZ/Y9c0cn9Mvfw/S4BEua
rHyfB8YiValNzk4QWKCvokeH7OosSboGDu68j5AVmEHxxedD/FodQAONgXy6HSws
q5GkXYbW6gSWF7MG4o81wDn7hBpUGlsuxwIDAQAB
-----END RSA PUBLIC KEY-----

去掉首尾的標籤和換行符,剩下的是一串base64字符串;
對其進行base64解碼,得到由ANS.1格式的公約編碼。
ANS.1是一種TLV形式的序列化格式(和protobuf有一點相似)。
將以上公鑰格式化顯示如下:

30 82 01 0a
   02 82 01 01
      00c451241f7153086151....省略部分字節
   02 03
      010001

對應PKCS#1形式的RSA公鑰的定義:

RSAPublicKey ::= SEQUENCE {
   modulus           INTEGER,  -- n
   publicExponent    INTEGER   -- e
}

我們就可以得到公鑰的“階”和“模”了(e和n)。
獲取私鑰的d和n也是類似。
關於密鑰格式的更多細節,可參考這篇文章:RSA密鑰格式解析

結合前面RSA計算過程分析,我們用openssl生成密鑰的話,就省去了大數選擇和密鑰的計算了。
接下來只需實現modPow函數即可。
筆者在Github找了一圈大數實現,沒找到滿意的,最終把目光投到JDK的大數類(BigInteger), 參照其實現,用C語言實現了modPow函數,然後結合key解析和padding,實現了完整的RSA加解密。

在非對稱加密算法中,除了RSA之外,被提的比較多的應該就是ECC了。
ECC是Elliptic Curve Cryptography(橢圓曲線加密)的縮寫,其實ECC主要是定義了一種封閉運算,基於ECC的ECDH和ECDSA纔是非對稱加密算法。

提到ECDH,需要先說DH算法。
不像RSA可以“加密”,DH算法本身不能加密,而是用於協商密鑰。
其簡單演示如下:

  1. 設定公共因子p;
  2. 客戶端計算A=a * p,發送A給服務端(這裏的*代表一種運算,並非乘法);
  3. 服務端計算B=b * p, 發送B給客戶端;
  4. 客戶端計算k1 = a * B = a * b * p;
  5. 服務端計算k2 = b * A = b * a * p;
  6. 於是服務隊和客戶端拿到約定的密鑰abp,然後可以用來做對稱加密或者HMAC等運算。

算法的有效性需要兩個條件:

  1. 計算a*p得到A比較容易,根據A和p計算a不可行(單向函數);
  2. 運算*具備交換律,以確保 a * b * p = b * a * p。

常規的乘法運算具備交換率,但是不滿足第一個條件,因爲通過除法運算可以計算a;
經典的DH算法,其運算爲 y = (g ^ x) %p ,公共因子爲g, p。
該運算滿足交換律;
同時,當g, p 爲較大的數值時,計算y = (g ^ x) % p相對容易(和RSA一樣,實現modPow函數即可),但根據g, p, y 計算x卻很困難。
但是爲確保安全性,p需要取較大的數值,比如1024bit的大整數,計算速度較慢(類似於RSA)。
而ECC所定義的封閉運算,所構造的單向函數的逆向成本要比mowPow要高,所以密鑰長度不需要很長,計算速度較快。
基於ECC運算的DH算法,被稱爲ECDH;類似的,基於ECC運算的DSA算法被稱爲ECDSA。
而ECDHE算法,其中的E是ephemeral(臨時性的),也就是,每次使用都重新生成密鑰。
網絡傳輸中,利用ECDHE可以實現通信的“向前安全”。
所謂的“向前安全”,就是指攻擊者記錄每次雙方的通信,在某一天獲取到私鑰時,是否能解碼之前記錄的通信。
假如每次所用的密鑰對都是臨時的,那麼攻擊者是無法解密之前的通信的。
ECDHE生成密鑰對很快,所以生成臨時密鑰的代價是可以接受的。

五、總結

本文比較概要地梳理了常見加密算法的一些知識點。
關於原理的部分也是簡要的描述,背後的數學原理之類的就不深入了,感興趣的朋友可以閱讀文末的參考鏈接。


參考資料

[1] 安全散列函數 https://www.cnblogs.com/cxchanpin/p/7141815.html
[2] HMAC算法及計算流程介紹 https://zhuanlan.zhihu.com/p/336054453
[3] AES簡介 https://www.cnblogs.com/luop/p/4334160.html
[4] AES算法描述及C語言實現 https://blog.csdn.net/shaosunrise/article/details/80219950
[5] 伽羅瓦域上的四則運算 https://blog.csdn.net/shelldon/article/details/54729687
[6] 另一種世界觀——有限域 https://www.bilibili.com/read/cv2922069
[7] RSA算法正確性證明 https://zhuanlan.zhihu.com/p/48994878
[8] 快速冪算法 https://blog.csdn.net/qq_19782019/article/details/85621386
[9] 蒙哥馬利算法 https://blog.csdn.net/weixin_46395886/article/details/112988136
[10] RSA密鑰格式解析 https://www.jianshu.com/p/c93a993f8997
[11] RSA加密的填充方式 https://blog.51cto.com/u_13520299/2656705
[12] DSA-數據簽名算法 https://blog.csdn.net/aaqian1/article/details/89299520
[13] Diffie-Hellman密鑰交換 https://juejin.cn/post/6844903881093169159
[14] ECDHE密鑰交換算法 https://www.likecs.com/default/index/show?id=124371
[13] ECC橢圓曲線加密算法 https://zhuanlan.zhihu.com/p/66794410

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章