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个小的素数来替代。
- 假设p = 13, q = 19
- 计算素数的乘积。 n = p * q = 247。247这个整数用16进制表示就是0xF7,一共8个bit,那么这就可以称为RSA8的密钥.比RSA2048短很多很多。
- 计算欧拉函数 φ = (p - 1)(q - 1) = 216
- e固定取值65537, e和φ是互质的.(在RSA规则中e是固定的,但是从算法角度可以取任意和φ互质的数,为了直观,假设是17)
- 找到一个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到密钥信息中:
- 就先放一个字节的oid的target标记;
- 再放一个字节的oid的长度;
- 最后放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步:
- 对数据进行杂凑计算,得到比较短的哈希值.(之前说过RSA加密只能加密很短的数据)
- 对哈希值进行ASN.1编码,加上杂凑算法的国际OID,得到一串稍微长一点的DER数据
- 对DER数据进行PKCS#1Padding填充到密钥长度(填充规则和公钥加密不同)
- 使用私钥加密后得到签名值
下面我们来说明一下:
- 计算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完全不是一个体系,我会在另外一篇文章里说明。