­

加解密算法分析

日常開發中,無論你是使用什麼語言,都應該遇到過使用加解密的使用場景,比如接口數據需要加密傳給前端保證數據傳輸的安全;HTTPS使用證書的方式首先進行非對稱加密,將客戶端的私匙傳遞給服務端,然後雙方後面的通信都使用該私匙進行對稱加密傳輸;使用MD5進行文件一致性校驗,等等很多的場景都使用到了加解密技術。

很多時候我們對於什麼時候要使用什麼樣的加解密方式是很懵的。因為可用的加解密方案實在是太多,大家對加解密技術的類型可能不是很清楚,今天這篇文章就來梳理一下目前主流的加解密技術,本篇文檔只針對算法做科普性說明,不涉及具體算法分析。日常使用的加解密大致可以分為以下四類:

  1. 散列函數(也稱信息摘要)算法
  2. 對稱加密算法
  3. 非對稱加密算法
  4. 組合加密技術

1. 散列函數算法

聽名字似乎不是一種加密算法,類似於給一個對象計算出hash值。所以這種算法一般用於數據特徵提取。常用的散列函數包括:MD5、SHA1、SHA2(包括SHA128、SHA256等)散列函數的應用很廣,散列函數有個特點,它是一種單向加密算法,只能加密、無法解密。

1.1 MD5

先來看MD5算法,MD5算法是廣為使用的數據特徵提取算法,最常見的就是我們在下載一些軟件,網站都會提供MD5值給你進行校驗,你可以通過MD5值是否一致來檢查當前文件是否被別人篡改。MD5算法具有以下特點:

  1. 任意長度的數據得到的MD5值長度都是相等的;
  2. 對原數據進行任一點修改,得到的MD5值就會有很大的變化;
  3. 散列函數的不可逆性,即已知原數據,無法通過特徵值反向獲取原數據。(需要說明的是2004年的國際密碼討論年會(CRYPTO)尾聲,王小雲及其研究同事展示了MD5、SHA-0及其他相關雜湊函數的雜湊衝撞。也就是說,她找出了第一個 兩個值不同,但 MD5 值相同的碰撞的例子。這個應該不能稱之為破解
1.2 MD5用途:
  1. 防篡改。上面說過用於文件完整性校驗。
  2. 用於不想讓別人看到明文的地方。比如用戶密碼入庫,可以將用戶密碼使用MD5加密存儲,下次用戶輸入密碼登錄只用將他的輸入進行MD5加密與數據庫的值判斷是否一致即可,這樣就有效防止密碼泄露的風險。
  3. 用於文件秒傳。比如百度雲的文件秒傳功能可以用這種方式來實現。在你點擊上傳的時候,前端同學會先計算文件的MD5值然後與服務端比對是否存在,如果有就會告訴你文件上傳成功,即完成所謂的秒傳。

在JDK中提供了MD5的實現:java.security包中有個類MessageDigest,MessageDigest 類為應用程序提供信息摘要算法的功能,如 MD5 或 SHA 算法。信息摘要是安全的單向哈希函數,它接收任意大小的數據,輸出固定長度的哈希值。

MessageDigest 對象使用getInstance函數初始化,該對象通過使用 update 方法處理數據。任何時候都可以調用 reset 方法重置摘要。一旦所有需要更新的數據都已經被更新了,應該調用 digest 方法之一完成哈希計算。

對於給定數量的更新數據,digest 方法只能被調用一次。digest 被調用後,MessageDigest 對象被重新設置成其初始狀態。

下面的例子展示了使用JDK自帶的MessageDigest類使用MD5算法。同時也展示了如果使用了update方法後沒有調用digest方法,則會累計當前所有的update中的值在下一次調用digest方法的時候一併輸出:

package other;

import java.security.MessageDigest;

/**
 * @author: rickiyang
 * @date: 2019/9/13
 * @description:
 */
public class MD5Test {

    static char[] hex = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};

    public static void main(String[] args) {
        try {
            //申明使用MD5算法
            MessageDigest md5 = MessageDigest.getInstance("MD5");
            md5.update("a".getBytes());//
            System.out.println("md5(a)=" + byte2str(md5.digest()));
            md5.update("a".getBytes());
            md5.update("bc".getBytes());
            System.out.println("md5(abc)=" + byte2str(md5.digest()));
            //你會發現上面的md5值與下面的一樣
            md5.update("abc".getBytes());
            System.out.println("md5(abc)=" + byte2str(md5.digest()));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 將位元組數組轉換成十六進制字符串
     *
     * @param bytes
     * @return
     */
    private static String byte2str(byte[] bytes) {
        int len = bytes.length;
        StringBuffer result = new StringBuffer();
        for (int i = 0; i < len; i++) {
            byte byte0 = bytes[i];
            result.append(hex[byte0 >>> 4 & 0xf]);
            result.append(hex[byte0 & 0xf]);
        }
        return result.toString();
    }
}

輸出:

md5(a)=0CC175B9C0F1B6A831C399E269772661
md5(abc)=900150983CD24FB0D6963F7D28E17F72
md5(abc)=900150983CD24FB0D6963F7D28E17F72
2.1 SHA系列算法

Secure Hash Algorithm,是一種與MD5同源的數據加密算法。SHA算法能計算出一個數位信息所對應到的,長度固定的字串,又稱信息摘要。而且如果輸入信息有任何的不同,輸出的對應摘要不同的機率非常高。因此SHA算法也是FIPS所認證的五種安全雜湊算法之一。原因有兩點:一是由信息摘要反推原輸入信息,從計算理論上來說是極為困難的;二是,想要找到兩組不同的輸入信息發生信息摘要碰撞的幾率,從計算理論上來說是非常小的。任何對輸入信息的變動,都有很高的幾率導致的信息摘要大相徑庭。

SHA實際上是一系列算法的統稱,分別包括:SHA-1、SHA-224、SHA-256、SHA-384以及SHA-512。後面4中統稱為SHA-2,事實上SHA-224是SHA-256的縮減版,SHA-384是SHA-512的縮減版。各中SHA算法的數據比較如下表,其中的長度單位均為位:

類別 SHA-1 SHA-224 SHA-256 SHA-384 SHA-512
消息摘要長度 160 224 256 384 512
消息長度 小於264位 小於264位 小於264位 小於2128位 小於2128位
分組長度 512 512 512 1024 1024
計算字長度 32 32 32 64 64
計算步驟數 80 64 64 80 80

SHA-1算法輸入報文的最大長度不超過264位,產生的輸出是一個160位的報文摘要。輸入是按512 位的分組進行處理的。SHA-1是不可逆的、防衝突,並具有良好的雪崩效應。

上面提到的MessageDigest類同時也支持SHA系列算法,使用方式與MD5一樣,注意SHA不同的類型:

MessageDigest md = MessageDigest.getInstance("SHA");
MessageDigest md = MessageDigest.getInstance("SHA-224");
MessageDigest md = MessageDigest.getInstance("SHA-384");

2. 對稱加密算法

所謂的對稱加密,意味着加密者和解密者需要同時持有一份相同的密匙,加密者用密匙加密,解密者用密匙解密即可。

常用的對稱加密算法包括DES算法、AES算法等。 由於對稱加密需要一個秘鑰,而秘鑰在加密者與解密者之間傳輸又很難保證安全性,所以目前用對稱加密算法的話主要是用在加密者解密者相同,或者加密者解密者相對固定的場景。

對稱算法又可分為兩類:

第一種是一次只對明文中的單個位(有時對位元組)運算的算法稱為序列算法或序列密碼;

另一種算法是對明文的一組位進行運算,這些位組稱為分組,相應的算法稱為分組算法或分組密碼。現代計算機密碼算法的典型分組長度為64位――這個長度既考慮到分析破譯密碼的難度,又考慮到使用的方便性。

2.1 BASE64算法

我們很熟悉的BASE64算法就是一個沒有秘密的對稱加密算法。因為他的加密解密算法都是公開的,所以加密數據是沒有任何秘密可言,典型的防菜鳥不防程序員的算法。

BASE64算法作用:

  1. 用於簡單的數據加密傳輸;

  2. 用於數據傳輸過程中的轉碼,解決中文問題和特殊符號在網絡傳輸中的亂碼現象。

    網絡傳輸過程中如果雙方使用的編解碼字符集方式不一致,對於中文可能會出現亂碼;與此類似,網絡上傳輸的字符並不全是可打印的字符,比如二進制文件、圖片等。Base64的出現就是為了解決此問題,它是基於64個可打印的字符來表示二進制的數據的一種方法。

BASE64原理

BASE64的原理比較簡單,每當我們使用BASE64時都會先定義一個類似這樣的數組:

['A', 'B', 'C', ... 'a', 'b', 'c', ... '0', '1', ... '+', '/']

上面就是BASE64的索引表,字符選用了”A-Z、a-z、0-9、+、/” 64個可打印字符,這是標準的BASE64協議規定。在日常使用中我們還會看到「=」或「==」號出現在BASE64的編碼結果中,「=」在此是作為填充字符出現。

JDK提供了BASE64的實現:BASE64Encoder,我們可以直接使用:

//使用base64加密
BASE64Encoder encoder = new BASE64Encoder();  
String encrypt = encoder.encode(str.getBytes());  
//使用base64解密
BASE64Decoder decoder = new BASE64Decoder();  
String decrypt = new String(decoder.decodeBuffer(encryptStr));  
2.2 DES

DES (Data Encryption Standard),在很長時間內,許多人心目中「密碼生成」與DES一直是個同義詞。

DES是一個分組加密算法,典型的DES以64位為分組對數據加密,加密和解密用的是同一個算法。它的密鑰長度是56位(因為每個第8 位都用作奇偶校驗),密鑰可以是任意的56位的數,而且可以任意時候改變。

DES加密過程大致如下:

  1. 首先需要從用戶處獲取一個64位長的密碼口令,然後通過等分、移位、選取和迭代形成一套16個加密密鑰,分別供每一輪運算中使用;
  2. 然後將64位的明文分組M進行操作,M經過一個初始置換IP,置換成m0。將m0明文分成左半部分和右半部分m0 = (L0,R0),各32位長。然後進行16輪完全相同的運算(迭代),這些運算被稱為函數f,在每一輪運算過程中數據與相應的密鑰結合;
  3. 在每一輪迭代中密鑰位移位,然後再從密鑰的56位中選出48位。通過一個擴展置換將數據的右半部分擴展成48位,並通過一個異或操作替代成新的48位數據,再將其壓縮置換成32位。這四步運算構成了函數f。然後,通過另一個異或運算,函數f的輸出與左半部分結合,其結果成為新的右半部分,原來的右半部分成為新的左半部分。將該操作重複16次;
  4. 經過16輪迭代後,左,右半部分合在一起經過一個末置換(數據整理),這樣就完成了加密過程。

對於DES解密的過程大家猛然一想應該是使用跟加密過程相反的算法,事實上解密和加密使用的是一樣的算法,有區別的地方在於加密和解密在使用密匙的時候次序是相反的。比如加密的時候是K0,K1,K2……K15,那麼解密使用密匙的次序就是倒過來的。之所以能用相同的算法去解密,這跟DES特意設計的加密算法有關,感興趣的同學可以深入分析。

2.3 AES

高級加密標準(AES,Advanced Encryption Standard),與DES一樣,使用AES加密函數和密匙來對明文進行加密,區別就是使用的加密函數不同。

上面說過DES的密鑰長度是56比特,因此算法的理論安全強度是2^56。但以目前計算機硬件的製作水準和升級情況,破解DES可能只是山脈問題,最終NIST(美國國家標準技術研究所(National Institute of Standards and Technology))選擇了分組長度為128位的Rijndael算法作為AES算法。

AES為分組密碼,分組密碼也就是把明文分成一組一組的,每組長度相等,每次加密一組數據,直到加密完整個明文。在AES標準規範中,分組長度只能是128位,也就是說,每個分組為16個位元組(每個位元組8位)。密鑰的長度可以使用128位、192位或256位。密鑰的長度不同,推薦加密輪數也不同,如下表所示:

AES 密鑰長度(32位比特字) 分組長度(32位比特字) 加密輪數
AES-128 4 4 10
AES-192 6 4 12
AES-256 8 4 14

3. 非對稱加密

非對稱加密算法的特點是,秘鑰一次會生成一對,其中一份秘鑰由自己保存,不能公開出去,稱為「私鑰」,另外一份是可以公開出去的,稱為「公鑰」。

將原文用公鑰進行加密,得到的密文只有用對應私鑰才可以解密得到原文;

將原文用私鑰加密得到的密文,也只有用對應的公鑰才能解密得到原文;

因為加密和解密使用的是兩個不同的密鑰,所以這種算法叫作非對稱加密算法

與對稱加密算法的對比
  • 優點:其安全性更好,對稱加密的通信雙方使用相同的秘鑰,如果一方的秘鑰遭泄露,那麼整個通信就會被破解。而非對稱加密使用一對秘鑰,一個用來加密,一個用來解密,而且公鑰是公開的,秘鑰是自己保存的,不需要像對稱加密那樣在通信之前要先同步秘鑰。
  • 缺點:非對稱加密的缺點是加密和解密花費時間長、速度慢,只適合對少量數據進行加密。

在非對稱加密中使用的主要算法有:RSA、Elgamal、ESA、背包算法、Rabin、D-H、ECC(橢圓曲線加密算法)等。不同算法的實現機制不同。

非對稱加密工作原理

下面我們就看一下非對稱加密的工作原理。

  • 乙方生成一對密鑰(公鑰和私鑰)並將公鑰向其它方公開。
  • 得到該公鑰的甲方使用該密鑰對機密信息進行加密後再發送給乙方。
  • 乙方再用自己保存的另一把專用密鑰(私鑰)對加密後的信息進行解密。乙方只能用其專用密鑰(私鑰)解密由對應的公鑰加密後的信息。
  • 在傳輸過程中,即使攻擊者截獲了傳輸的密文,並得到了乙的公鑰,也無法破解密文,因為只有乙的私鑰才能解密密文。同樣,如果乙要回復加密信息給甲,那麼需要甲先公布甲的公鑰給乙用於加密,甲自己保存甲的私鑰用於解密。
非對稱加密鼻祖:RSA

RSA算法基於一個十分簡單的數論事實:將兩個大質數(素數)相乘十分容易,但是想要對其乘積進行因式分解卻極其困難,因此可以將乘積公開作為加密密鑰。比如:取兩個簡單的質數:67,73,得到兩者乘積很簡單4891;但是要想對4891進行因式分解,其工作量成幾何增加。

應用場景:

HTTPS請求的SSL層。

在JDK中也提供了RSA的實現,下面給出示例:

	/**
     * 創建密匙對
     *
     * @return
     */
    private KeyPair genKeyPair() {
        //創建 RSA Key 的生產者。
        KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");

        //利用用戶密碼作為隨機數初始化出 1024 比特 Key 的生產者。
        //SecureRandom 是生成安全隨機數序列,password.getBytes() 是種子,只要種子相同,序列就一樣。
        keyPairGen.initialize(1024, new SecureRandom("password".getBytes()));

        //創建密鑰對
        return keyPairGen.generateKeyPair();
    }


    /**
     * 生成公匙
     *
     * @return
     */
    public PublicKey genPublicKey() {
        try {
            //創建密鑰對
            KeyPair keyPair = genKeyPair();

            //生成公鑰
            PublicKey publicKey = keyPair.getPublic();
            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKey.getEncoded());
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            publicKey = keyFactory.generatePublic(keySpec);
            return publicKey;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;

    }

    /**
     * 生成私匙
     *
     * @return
     */
    public PrivateKey genPrivateKey() {
        try {
            //創建密鑰對
            KeyPair keyPair = genKeyPair();

            //生成私匙
            PrivateKey privateKey = keyPair.getPrivate();
            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(privateKey.getEncoded());
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            return keyFactory.generatePrivate(keySpec);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 公鑰加密
     *
     * @param data
     * @param publicKey
     * @return
     * @throws Exception
     */
    public static byte[] encryptByPublicKey(byte[] data, String publicKey)
            throws Exception {
        X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(publicKey.getBytes());
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        Key publicK = keyFactory.generatePublic(x509KeySpec);
        // 對數據加密
        Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
        cipher.init(Cipher.ENCRYPT_MODE, publicK);
        int inputLen = data.length;
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        int offSet = 0;
        byte[] cache;
        int i = 0;
        // 對數據分段加密
        while (inputLen - offSet > 0) {
            if (inputLen - offSet > 117) {
                cache = cipher.doFinal(data, offSet, 117);
            } else {
                cache = cipher.doFinal(data, offSet, inputLen - offSet);
            }
            out.write(cache, 0, cache.length);
            i++;
            offSet = i * 117;
        }
        byte[] encryptedData = out.toByteArray();
        out.close();
        return encryptedData;
    }

    /**
     * 私鑰解密
     *
     * @param encryptedData
     * @param privateKey
     * @return
     * @throws Exception
     */
    public static byte[] decryptByPrivateKey(byte[] encryptedData,
                                             String privateKey) throws Exception {
        PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(privateKey.getBytes());
        KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
        Key privateK = keyFactory.generatePrivate(pkcs8KeySpec);
        Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
        cipher.init(Cipher.DECRYPT_MODE, privateK);
        int inputLen = encryptedData.length;
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        int offSet = 0;
        byte[] cache;
        int i = 0;
        // 對數據分段解密
        while (inputLen - offSet > 0) {
            if (inputLen - offSet > 118) {
                cache = cipher.doFinal(encryptedData, offSet, 118);
            } else {
                cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet);
            }
            out.write(cache, 0, cache.length);
            i++;
            offSet = i * 118;
        }
        byte[] decryptedData = out.toByteArray();
        out.close();
        return decryptedData;
    }

    /**
     * 私鑰加密
     *
     * @param data
     * @param privateKey
     * @return
     * @throws Exception
     */
    public static byte[] encryptByPrivateKey(byte[] data, String privateKey)
            throws Exception {
        PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(publicKey.getBytes());
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        Key privateK = keyFactory.generatePrivate(pkcs8KeySpec);
        Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
        cipher.init(Cipher.ENCRYPT_MODE, privateK);
        int inputLen = data.length;
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        int offSet = 0;
        byte[] cache;
        int i = 0;
        // 對數據分段加密
        while (inputLen - offSet > 0) {
            if (inputLen - offSet > 117) {
                cache = cipher.doFinal(data, offSet, 117);
            } else {
                cache = cipher.doFinal(data, offSet, inputLen - offSet);
            }
            out.write(cache, 0, cache.length);
            i++;
            offSet = i * 117;
        }
        byte[] encryptedData = out.toByteArray();
        out.close();
        return encryptedData;
    }

	/**
     * 公鑰解密
     *
     * @param encryptedData
     * @param publicKey
     * @return
     * @throws Exception
     */
    public static byte[] decryptByPublicKey(byte[] encryptedData,
                                            String publicKey) throws Exception {
        X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(publicKey.getBytes());
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        Key publicK = keyFactory.generatePublic(x509KeySpec);
        Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
        cipher.init(Cipher.DECRYPT_MODE, publicK);
        int inputLen = encryptedData.length;
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        int offSet = 0;
        byte[] cache;
        int i = 0;
        // 對數據分段解密
        while (inputLen - offSet > 0) {
            if (inputLen - offSet > 118) {
                cache = cipher.doFinal(encryptedData, offSet, 118);
            } else {
                cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet);
            }
            out.write(cache, 0, cache.length);
            i++;
            offSet = i * 118;
        }
        byte[] decryptedData = out.toByteArray();
        out.close();
        return decryptedData;
    }

4. 組合加密

上面介紹的3種加密技術,每一種都有自己的特點,比如散列技術用於特徵值提取,對稱加密速度雖快但是有私匙泄露的危險,非對稱加密雖然安全但是速度卻慢。基於這些情況,現在的加密技術更加趨向於將這些加密的方案組合起來使用,基於此來研發新的加密算法。

MAC(Message Authentication Code,消息認證碼算法)是含有密鑰散列函數算法,兼容了MD和SHA算法的特性,並在此基礎上加上了密鑰。因此MAC算法也經常被稱作HMAC算法。MAC(Message Authentication Code,消息認證碼算法)是含有密鑰散列函數算法,HMAC加密可以理解為加鹽的散列算法,此處的「鹽」就相當於HMAC算法的秘鑰。

HMAC算法的實現過程需要一個加密用的散列函數(表示為H)和一個密鑰。

經過MAC算法得到的摘要值也可以使用十六進制編碼表示,其摘要值得長度與實現算法的摘要值長度相同。例如 HmacSHA算法得到的摘要長度就是SHA1算法得到的摘要長度,都是160位二進制數,換算成十六進制的編碼為40位。

MAC算法的實現:

算法 摘要長度 備註
HmacMD5 128 JAVA6實現
HmacSHA1 160 JAVA6實現
HmacSHA256 256 JAVA6實現
HmacSHA384 384 JAVA6實現
HmacSHA512 512 JAVA6實現
HmacMD2 128 BouncyCastle實現
HmacMD4 128 BouncyCastle實現
HmacSHA224 224 BouncyCastle實現

過程如下:

  1. 在密鑰key後面添加0來創建一個長為B(64位元組)的字符串(str);
  2. 將上一步生成的字符串(str) 與ipad(0x36)做異或運算,形成結果字符串(istr);
  3. 將數據流data附加到第二步的結果字符串(istr)的末尾;
  4. 做md5運算於第三步生成的數據流(istr);
  5. 將第一步生成的字符串(str) 與opad(0x5c)做異或運算,形成結果字符串(ostr),再將第四步的結果(istr) 附加到第五步的結果字符串(ostr)的末尾做md5運算於第6步生成的數據流(ostr),最終輸出結果(out)

注意:如果第一步中,key的長度klen大於64位元組,則先進行md5運算,使其長度klen = 16位元組。

JDK中的實現:

public static void jdkHmacMD5() {
    try {
        // 初始化KeyGenerator
        KeyGenerator keyGenerator = KeyGenerator.getInstance("HmacMD5");
        // 產生密鑰
        SecretKey secretKey = keyGenerator.generateKey();
        // 獲取密鑰
        byte[] key = secretKey.getEncoded();
        //            byte[] key = Hex.decodeHex(new char[]{'1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e'});
        // 還原密鑰
        SecretKey restoreSecretKey = new SecretKeySpec(key, "HmacMD5");
        // 實例化MAC
        Mac mac = Mac.getInstance(restoreSecretKey.getAlgorithm());
        // 初始化MAC
        mac.init(restoreSecretKey);
        // 執行摘要
        byte[] hmacMD5Bytes = mac.doFinal("data".getBytes());
        System.out.println("jdk hmacMD5:" + new String(hmacMD5Bytes));
    } catch (Exception e) {
        e.printStackTrace();
    }
}