2.4小時教你精通RSA加解密、簽名驗簽算法


2.4小時教你精通RSA加解密、簽名驗簽算法

現在很流行什么24小時精通xxx,我覺得24小時太久,不如試試2.4小時。   
而且我敢說,認真看完這個,真的是可以精通,不是入門哦。

RSA簡介

RSA加密算法是1977年由羅納德·李維斯特(Ron Rivest)、阿迪·薩莫爾(Adi Shamir)和倫納德·阿德曼(Leonard Adleman)一起提出的。   
RSA是非對稱算法,握有一對公私鑰。使用公鑰加密,私鑰解密。使用私鑰簽名,公鑰驗簽。

RSA加密數學原理

    RSA加密采用了大質數難以分解的原理來進行加密和解密。至於更深的原理需要具備一些數論的知識。這里可以先不管。
    對極大整數做因數分解的難度決定了RSA算法的可靠性。換言之,對一極大整數做因數分解愈困難,RSA算法愈可靠。因此RSA具有1024,2048,3072,4096這4
種長度的密鑰.其中1024位的已經不鼓勵使用了.
要學會RSA加密,只需要知道以下2個公式,就掌握了基本內功:

pow(x, e) % n = y; // x的e次方模n得到y
pow(y, d) % n = x; // y的d次方模n得到x

通過公式1就可以將x轉換成y,通過公式2就可以將y轉換成x。實際上,這就是加密和解密的過程。看起來十分簡單。
那么加密使用公鑰,公鑰就可以理解成e和n的組合。解密使用私鑰,私鑰就是d和n的組合。
記住:公鑰 == (e, n), 私鑰 == (d, n)
那么e,n,d都是什么呢?怎么產生的呢?步驟如下,產生2個大的素數p和q,為了方便解說,咱們用2個小的素數來替代。

  1. 假設p = 13, q = 19
  2. 計算素數的乘積。 n = p * q = 247。247這個整數用16進制表示就是0xF7,一共8個bit,那么這就可以稱為RSA8的密鑰.比RSA2048短很多很多。
  3. 計算歐拉函數 φ = (p - 1)(q - 1) = 216
  4. e固定取值65537, e和φ是互質的.(在RSA規則中e是固定的,但是從算法角度可以取任意和φ互質的數,為了直觀,假設是17)
  5. 找到一個d可以滿足e * d = 1 (mod φ)。 也就是找到一個整數d,可以使得e*d被φ除的余數為1
    這個公式5實際上等於e * d - 1 = kφ.實際上就是e * a + φ * b = 1的公式求解。可以用擴展歐幾里德算法求解。
    在p=13,q=19,e=17的情況下,則17a+216b=1,我們可以得到其中一個答案a=89,b=-7.那么d就等於這個a,也就是d=89。

假設現在有一個明文數字是101,我們加密后,就是101的17次方模247,也就是密文結果是225
然后用密文225去解密,也就是225的89次方模247,得到明文101

    @Test
    public void testRSA() {
        int plainText = 101;
        int n = 247;
        int e = 17;
        int d = 89;
        /* 加密:plain的e次方,模n*/
        BigInteger cipher = BigInteger.valueOf(plainText).modPow(BigInteger.valueOf(e), BigInteger.valueOf(n));
        log.debug("{}", cipher.intValue());
        /* 解密:cipher的d次方,模n*/
        BigInteger plain = cipher.modPow(BigInteger.valueOf(d), BigInteger.valueOf(n));
        log.debug("{}", plain.intValue());
        Assertions.assertEquals(plain.intValue(), plainText);
    }

以上就是加解密的基本原理了。
但是基本原理僅僅是實驗室水准,拿出來壓根沒辦法用!!!
實際加密還受到很多PKI體系的規則約束,比如PKCS#1。

PKCS#1

    Public-Key Cryptography Standards (PKCS)是由美國數據安全公司及其合作伙伴制定的一組公鑰密碼學標准,
其中包括證書申請、證書更新、證書作廢表發布、擴展證書內容以及數字簽名、數字信封的格式等方面的一系列相關協議。
大家可以認為這是實際上的國際標准。
    對稱密鑰往往很簡單,比如一個16個字節的AES密鑰,那就是光禿禿16個字節,可以用base64.key的格式保存起來就OK了,很粗暴。
對稱密鑰只要長度正確,往往可以用於不同的算法,都是通用的。
    而非對稱密鑰就復雜多了,一個非對稱密鑰只能用於它自身的算法,為了可以精准識別,非對稱密鑰數據往往還包含了各種信息。
為了把這些密鑰信息和密鑰數據柔和在一起,可以有很多方法. 我們現在往往可以使用Json、XML、Yaml等方式.但是早期沒有這些,因此PKCS組織指定了一種標准協議,叫ASN.1。
咱們這里只對ASN.1說一個簡短的介紹,更深的讀者自己去學習。
    ASN.1就是一個Target-Length-Value(TLV)的結構,簡單的說,舉個例子,現在我要放一個oid到密鑰信息中:

  1. 就先放一個字節的oid的target標記;
  2. 再放一個字節的oid的長度;
  3. 最后放N個字節的oid的內容,就OK了。

    RSA密鑰常見的格式主要如下:
公鑰:PKCS#1, X.509
私鑰:PKCS#1, PKCS#8
PKCS#1的RSA公鑰格式:

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

PKCS#1的RSA私鑰格式:

RSAPrivateKey ::= SEQUENCE {
    version Version ,
    modulus INTEGER , -- n
    publicExponent INTEGER , -- e
    privateExponent INTEGER , -- d
    prime1 INTEGER , -- p
    prime2 INTEGER , -- q
    exponent1 INTEGER , -- d mod (p-1)
    exponent2 INTEGER , -- d mod (q-1)
    coefficient INTEGER , -- (inverse of q) mod p
    otherPrimeInfos OtherPrimeInfos OPTIONAL
}

可以發現這本質上和JSON,XML都差不多。本來私鑰只需要放n和d就夠了,但是為了方便計算,把中間的各種變量和公鑰數據都放進去了,因此得到RSA的私鑰就可以直接得到公鑰了。
為了把密鑰變成容易復制的文本和容易被人類分辨的。因此會將二進制的ASN.1協議組裝好的數據用base64編碼后變成可見文本。但是這只解決了復制問題,人類看起來還是不知道是什么密鑰。
因此還有一種PEM格式.就是在BASE64文本的頭尾各加一行字符串,這樣人類就可以看懂了。
PKCS#1的RSA公鑰PEM格式:

-----BEGIN RSA PUBLIC KEY-----
BASE64編碼的密鑰文本
-----END RSA PUBLIC KEY-----

PKCS#1的RSA私鑰PEM格式:

-----BEGIN RSA PRIVATE KEY-----
BASE64編碼的密鑰文本
-----END RSA PRIVATE KEY-----

早期這樣就夠用了,因為這是第一代標准,因此叫PKCS#1。但是后來非對稱密鑰出現了橢圓曲線算法,這樣RSA就不是唯一的非對稱算法了,就出現了不統一的情況。
於是隨着時代的發展,推出了X.509和PKCS#8的標准。以后所有的標准公鑰都必須符合X.509,而標准私鑰都必須符合PKCS#8。那是怎么做到的呢?原理很簡單,在
PKCS#1的標准上加一個頭信息,頭信息中標明改密鑰是什么算法,然后就可以走不同的分支了。於是我們看看格式內容:
X.509的RSA公鑰格式:

RSAPublicKey ::= SEQUENCE {
   algorithm AlgorithmIdentifier , // 這就是增加的頭信息
   publicKey RSAPublicKey  // 這就是PKCS#1的RSA公鑰的內容
}

PKCS#8的RSA私鑰格式:

PrivateKey ::= SEQUENCE {
    version Version ,  // 這就是增加的頭信息
    privateKeyAlgorithm PrivateKeyAlgorithmIdentifier , // 這也是增加的頭信息
    privateKey RSAPrivateKey // 這就是PKCS#1的RSA私鑰的內容
}

同樣的為了人類方便識別,也有對應的PEM格式的文本頭尾:
X.509的RSA公鑰PEM格式:

-----BEGIN PUBLIC KEY-----
BASE64編碼的密鑰文本
-----END PUBLIC KEY-----

PKCS#8的RSA私鑰PEM格式:

-----BEGIN PRIVATE KEY-----
BASE64編碼的密鑰文本
-----END PRIVATE KEY-----

看見沒?區別就是少了字符串RSA,因為是通用的了嘛!

填充規則

介紹完基本的PKCS體系后,咱們繼續進行密碼算法計算,來點干貨.在加密數學原理中,我們可以發現被加密的明文其實是不能超過密鑰長度的,為什么呢?
因為e的值必須比n小,在數論體系中才可以滿足這個公式。那么為了方便識別明文,PKCS規定了RSA加密的填充規則,我們叫PKCS1Padding,現在一般使用的是
V1.5版本。(還有OAEP等padding規則,此處不展開說).
PKCS規定,使用RSA公鑰加密的時候,需要在明文前加一個0x00的備用字節,然后加一個0x02,表示是公鑰加密,然后加一段沒有0的隨機數據,然后加一個0x00結尾,然后加明文數據.
讓這些長度恰好等於密鑰長度。以RSA2048為例,密鑰長2048比特,也就是256個字節.
那么假設我們需要加密100個字節的明文,在加密前,需要人為填充成如下結構

新明文 == 0x00 + 0x02 + (256 - 103)字節的非0隨機數 + 0x00 + (100)字節的原明文

這樣新明文就恰好是256個字節了。因此實際使用中,明文的長度不能超過密鑰長度減去3字節。解密后再根據這個規則反推出真正的明文。

talk is cheap, show you the code

光說不練假把式,流於表面,咱們上代碼來模擬一次用自己實現的算法加密,然后用JDK自己去解密,看看能不能成功?
先實現一下這個加密填充規則:

    private static final byte ENCRYPT_PAD_FLAG = (byte) 0x02;
    private static final byte HEAD = (byte) 0x00;
    private static final byte TAIL = (byte) 0x00;
    /**
     * RSA公鑰加密時,填充明文到密鑰長度
     *
     * @param msg     數據
     * @param keySize 密鑰bit位數
     * @return 填充后的數據
     */
    private byte[] padEncrypt(byte[] msg, int keySize) {
        int keyLen = keySize / Byte.SIZE;
        int msgLen = msg.length;
        int padLen = keyLen - msgLen - 3; // 3 == 0x00+0x02+0x00
        byte[] pad = getNoZeroRand(padLen);
        ByteBuffer buf = ByteBuffer.allocate(keyLen);
        buf.put(HEAD);
        buf.put(ENCRYPT_PAD_FLAG);
        buf.put(pad);
        buf.put(TAIL);
        buf.put(msg);
        return buf.array();
    }

    /**
     * 模擬產生沒有0的隨機數
     *
     * @param len 長度
     * @return 隨機數
     */
    private byte[] getNoZeroRand(int len) {
        byte[] buf = new byte[len];
        ThreadLocalRandom.current().nextBytes(buf);
        for (int i = 0; i < len; i++) {
            while (buf[i] == 0) {
                byte[] tmp = new byte[1];
                ThreadLocalRandom.current().nextBytes(tmp);
                buf[i] = tmp[0];
            }
        }
        return buf;
    }

加密實戰

然后我們使用JDK產生一對2048的RSA密鑰,自己加密,JDK解密

    @Test
    public void testMeEncryptAndJceDecrypt() throws NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
        KeyPairGenerator generator = KeyPairGenerator.getInstance(RSA);
        generator.initialize(_2048);
        KeyPair pair = generator.generateKeyPair();
        RSAPublicKey pk = (RSAPublicKey) pair.getPublic();
        RSAPrivateCrtKey sk = (RSAPrivateCrtKey) pair.getPrivate();
        BigInteger n = pk.getModulus();
        BigInteger e = pk.getPublicExponent();
        /* 模擬明文 */
        byte[] plainText = new byte[100];
        ThreadLocalRandom.current().nextBytes(plainText);
        log.debug("{}", Hex.toHexString(plainText));
        byte[] padPlainText = padEncrypt(plainText, _2048);
        /* 自己加密,cipherText == plainText^e % n */
        BigInteger plainInteger = new BigInteger(padPlainText);
        /* modPow()方法內置了基於數論的算法,比自己pow后再mod快很多很多很多很多 */
        BigInteger cipherInteger = plainInteger.modPow(e, n);
        byte[] cipherTextMy = cipherInteger.toByteArray();
        /* JCE解密 */
        Cipher cipher = Cipher.getInstance(RSA);
        cipher.init(Cipher.DECRYPT_MODE, sk);
        byte[] p = cipher.doFinal(cipherTextMy);
        Assertions.assertArrayEquals(plainText, p);
        log.debug("END");
    }

我們先用padEncrypt把隨機產生的明文填充后,在使用e和n進行加密,最后用JDK進行解密,比對一致。

可以看見,完全一致。可以證明咱們的加密算法已經走出了實驗室,完全可以用於實際場景。

總結一下:    
實際加密時要按規則把明文填充到密鑰長度,因此加密后到密文長度肯定也是密鑰長度,由於有隨機數填充,那么密文每次都會不同(除非長度恰好只填充了規則里的3字節)    
為什么要讓明文填充到密鑰長度呢?是為了讓數據盡可能長一點,加大被破解的難度。

簽名實戰

RSA除了用公鑰加密數據,用私鑰解密數據外。還可以用私鑰簽名數據,用公鑰驗簽數據,來證明數據的合法性。
那么如何簽名呢?
所謂簽名其實就是用私鑰加密,而驗簽就使用公鑰解密。當然這個說法依然僅僅是實驗室級別的,實際上的簽名過程還是需要有好幾步的,這樣才符合PKCS規范。
常見的RSA簽名算法有SHA256WithRSA、SHA128WithRSA、SHA1WithRSA等等,看到區別了嗎?就是一個雜湊算法名+with+RSA。
是什么意思呢?簡單的說,對一串數據進行RSA簽名分4步:

  1. 對數據進行雜湊計算,得到比較短的哈希值.(之前說過RSA加密只能加密很短的數據)
  2. 對哈希值進行ASN.1編碼,加上雜湊算法的國際OID,得到一串稍微長一點的DER數據
  3. 對DER數據進行PKCS#1Padding填充到密鑰長度(填充規則和公鑰加密不同)
  4. 使用私鑰加密后得到簽名值
        下面我們來說明一下:
  • 計算HASH,沒啥好說的,SHA256WithRSA就用SHA256算法計算哈希,XXXWithRSA就用XXX算法計算哈希
  • ASN.1編碼需要將XXX雜湊算法的OID編碼進去,舉個例子SHA256的國際OID是2.16.840.1.101.3.4.2.1
  • 私鑰簽名填充規則和公鑰加密填充規則不同,但是大體差不多。
    PKCS#1規定,使用RSA私鑰簽名的時候,需要在DER數據前加一個0x00的備用字節,然后加一個0x01,表示是私鑰簽名,然后加一段全是0xFF的數據,然后加一個0x00結尾,然后加DER數據.
    讓這些長度恰好等於密鑰長度。以RSA2048為例,密鑰長2048比特,也就是256個字節.
    那么假設我們需要對100個字節的DER進行填充,在簽名前,需要人為填充成如下結構
   新數據 == 0x00 + 0x01 + (256 - 103)個字節的0xFF + 0x00 + (100)字節的DER數據  
  • 最后用私鑰加密

talk is cheap, show you the code

以SHA256WithRSA簽名算法為例。
我們先實現給哈希添加OID的編碼:

    /**
     * 給哈希值加上oid等信息
     *
     * @param oid    oid
     * @param digest 哈希
     * @return DER數據
     * @throws IOException IO異常
     */
    public byte[] addOid(ObjectIdentifier oid, byte[] digest)
            throws IOException {
        DerOutputStream out = new DerOutputStream();
        new AlgorithmId(oid).encode(out);
        out.putOctetString(digest);
        DerValue result = new DerValue(DerValue.tag_Sequence, out.toByteArray());
        return result.toByteArray();
    }

    /**
     * 計算SHA-256
     * 
     * @param msg 消息
     * @return 雜湊
     * @throws NoSuchAlgorithmException 異常
     */
    private byte[] getSha256(byte[] msg) throws NoSuchAlgorithmException {
        MessageDigest digest = MessageDigest.getInstance(SHA256);
        return digest.digest(msg);
    }

使用ASN.1編碼后的樣子大概是這樣的:

然后我們實現一下padding填充規則:

    private static final byte SIGN_PAD_FLAG = (byte) 0x01;
    private static final byte FF = (byte) 0xFF;
    /**
     * 簽名填充
     *
     * @param msg 數據
     * @param keySize RSA密鑰bit位數
     * @return 結果
     */
    private byte[] padSign(byte[] msg, int keySize) {
        int keyLen = keySize / Byte.SIZE;
        int msgLen = msg.length;
        int padLen = keyLen - msgLen - 3;
        byte[] pad = new byte[padLen];
        Arrays.fill(pad, FF);
        ByteBuffer buf = ByteBuffer.allocate(keyLen);
        buf.put(HEAD);
        buf.put(SIGN_PAD_FLAG);
        buf.put(pad);
        buf.put(TAIL);
        buf.put(msg);
        return buf.array();
    }

准備工作做完后就可以開始正餐了.我們來模擬一次用自己實現的算法進行簽名,然后讓JDK也去簽名一次,如果完全一致,則說明成功,都不需要驗簽了。

    private static final String SHA256 = "SHA-256";
    private static final String SHA256_WITH_RSA = "SHA256WithRSA";
    @Test
    public void testMeSignAndJceVerify() throws NoSuchAlgorithmException, InvalidKeyException, SignatureException, IOException {
        KeyPairGenerator generator = KeyPairGenerator.getInstance(RSA);
        generator.initialize(_2048);
        KeyPair pair = generator.generateKeyPair();
        RSAPublicKey pk = (RSAPublicKey) pair.getPublic();
        RSAPrivateCrtKey sk = (RSAPrivateCrtKey) pair.getPrivate();
        /* 模擬數據 */
        byte[] plainText = new byte[50];
        ThreadLocalRandom.current().nextBytes(plainText);
        /* 用自己實現的算法簽名 */
        /* 1.先用SHA256計算雜湊 */
        byte[] hash = getSha256(plainText);
        /* 2.ASN.1添加OID信息變成DER數據 */
        byte[] der = addOid(AlgorithmId.SHA256_oid, hash);
        /* 3.填充 */
        byte[] msg = padSign(der, _2048);
        /* 4.私鑰加密最后的msg */
        BigInteger msgInteger = new BigInteger(msg);
        BigInteger v = msgInteger.modPow(sk.getPrivateExponent(), sk.getModulus()); // 私鑰的d & n
        byte[] sign = v.toByteArray();
        log.debug("{}", Hex.toHexString(sign));
        /* JCE簽名 */
        Signature signature = Signature.getInstance(SHA256_WITH_RSA);
        signature.initSign(sk);
        signature.update(plainText);
        byte[] sign1 = signature.sign();
        Assertions.assertArrayEquals(sign1, sign);
        /* JCE驗簽 */
        signature = Signature.getInstance(SHA256_WITH_RSA);
        signature.initVerify(pk);
        signature.update(plainText);
        boolean ret = signature.verify(sign);
        Assertions.assertTrue(ret);
    }


至此, RSA的簽名算法也靠自己完全實現了,驗簽無非就是一個逆過程,讀者可以自己去實踐。

總結一下,簽名值的長度肯定是和密鑰長度完全一致,RSA2048就肯定是256字節,RSA1024就肯定是128字節,以此類推,由於簽名填充使用0xFF,因此同樣的數據使用同樣的私鑰簽名,結果是一致的。這一點和加密不同。  

PS: 橢圓曲線的非對稱算法和RSA完全不是一個體系,我會在另外一篇文章里說明。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM