本文会涉及的跟RSA相关的概念包括:公钥、私钥、非对称加密、 ASN.1 、 DER 编码、PEM、PKC#1、PKC#8、数字签名、csr证书签名请求、数字证书、证书链
RSA的疑惑
对于不是专门研究算法的同学来说,如果会跟RSA打交道,大部分情况都是做接口参数的签名生成或校验,也就是依据对接文档开发处理签名。而由于围绕着RSA的相关的概念很多(你看前面提到的概念,能不迷糊吗),如果写文档的人/对接的人不熟悉RSA,没讲清楚细节,那对接起来真想口吐芬芳,比如说(文章过程和最后面会解答)
- 只讲了用RSA生成签名,没说明签名类型;
- 对接方提供的配置是证书,开发不熟悉,把公钥和证书混淆;
- 验证签名要根据接口参数的证书序列号找到对应的证书进行验证(一个接口会有不同私钥生成签名的情况);
- 仿照Https通信,接口参数携带自签证书,要求构建证书链验证签名
大部分会遇到的情况是前两者,后面两个情况对使用者的要求会高点,对数字证书足够的了解。这几种情况基本是使用RSA做数字签名。这时候你可能要喊:“打住打住,兄弟,RSA不是非对称加解密吗?怎么都是签名?”且听我继续讲下去,关于RSA你是否还有以下疑惑呢?
- 对于加解密功能,有我擅长用的对称加密 AES不都够了吗?
- RSA是如何用不同密钥,实现加解密的能力的?
- 我们经常听到的是公钥加密消息,私钥解密获得原文,那么能不能反过来?
- 关于RSA,是不是认为它除了加解密,还可以像md5一样可以生成哈希值作为签名?
- RSA的典型应用https,是怎么保障校验"我"就是"我"的 (可能没有这个疑问,毕竟老八股文了)?
RSA为何而生
抛开场景讲功能,都是空中楼阁,其创造必定是为了解决它的前辈(对称加密)解决不了的问题。RSA加密算法主要是解决了密钥交换的安全性问题。
对称加密算法需要在通信双方之间共享密钥,但在密钥分发方面存在困难,因为密钥是一样的,我把密钥发送给你,中间就有可能被拦截或后期发生泄露,整个通信的安全性就会受到威胁。
RSA算法使用了公钥和私钥的非对称性质,允许通信双方使用公私钥进行加密、解密信息,只需要交换公钥,提供了更安全的密钥交换方式。即使公钥拦截或泄漏了,也只有私钥的持有方能够生成加密的消息,接收方只能解密正确加密的信息,而不是被伪造的消息。
RSA加解密原理
你会不会觉得很神奇,一把锁被一把钥匙锁住了,只能用另一把唯一的锁来开。对于加解密来说,本质上就是算法逻辑的计算,经过一轮捣鼓后又变回了原样。
从开发的逻辑考虑,一个内容可以被还原回去,肯定得像对称密钥一样知己知彼才能做到。
就好比 em + dc = 1, m为原文(输入),c为密文(输出),要同时知道e和d,才能计算得到c。同样的,要把c计算得到m, 同样也得知道e和d。不能一方拿着e,另一方拿着d,大家都知道表达式ec + dm = 1,但是没法计算。那就是要有关联才能继续玩了,那一方拿着e和n,另一方拿着d和n呢,假设 n = e*d,那这样就可以算了,当然这例子看起来漏洞百出,相当于我有了公钥,能算出私钥是什么值,何谈安全?
然而事实上,RSA就是类似这样,公钥包含了参数[n,e],私钥包含了参数[n,d],只不过RSA是用了不同数学公式,保证加解密是正确的,而且即使你知道n、e和d,彼此是有关联并且公开的,也很难通过你手里的公钥[n,e],算出d。
RSA加密的数学原理基于两个主要数论概念:大素数的乘积与模幂运算。RSA数学原理详细推导
- 大素数的乘积:选择两个大素数 p 和 q。计算它们的乘积 n = p * q,这个 n 就是 RSA 加密中的模数(modulus)。n 的大小决定了加密的强度和密钥的长度。
- 欧拉函数 φ(n):对于两个素数 p 和 q,它们的欧拉函数 φ(n) = (p - 1) * (q - 1)。φ(n) 表示小于 n 且与 n 互素的正整数个数。
- 选择公钥和私钥:选择一个与 φ(n) 互素的数 e,作为公钥的指数(encryption exponent)。然后,计算私钥的指数 d,使得 (d * e) % φ(n) = 1,d 为私钥的指数(decryption exponent)。
- 加密过程:对消息 m 进行加密时,利用公钥指数 e 对消息进行模幂运算,得到密文 c = m^e % n。
- 解密过程:接收到密文 c 后,使用私钥指数 d 对密文进行模幂运算,得到原始消息 m = c^d % n。
总结对我们最有用的信息,就是我们生成三个数字模n、幂e、幂d,一方持有[n,e],另一方持有[n,d],就能实现非对称加解密。我们用比较小的数字来验证下这个加解密过程,可以用底部的代码自行测试。
- 选择两个素数 q=7, p=11,计算 n= 7 * 11 = 77
- 计算 φ(n) = (p - 1) * (q - 1) = 60,
- 找一个与 φ(n) =60互质的数e=13, 再由(d * e) % φ(n) = 1的推导公式 d*e + kφ(n) = 1, 计算符合的d=37
- 加密原文m=7 , 验证解密是否能得到7 (密文需要比n小,非数字则转成对应的10进制后计算,计算机底层是2进制;加密内容超过的话,RSA加密过程会进行分组)
- 公钥加密:c = m^e % n = 7 ^ 13 % 77 = 35
- 私钥解密:m = c^d % n = 35 ^ 37 % 77 = 7
不得不说数学是皇冠上的明珠。也就是说我们公钥只要有了[n,e],私钥是[n,d]就能实现加解密,再回过头看下前面我漏洞百出的例子提到过公私钥会有一定的关联,那RSA公钥[n,e]和私钥[n,d]的关联就是 e*d≡1(modϕ(n)) ,是实现加解密的关键,(其中加解密能实现的原理可以看RSA数学原理详细推导,对数学的知识水平要求不高)
从这个表达式 e*d≡1(modϕ(n)) 以及推导过程,我们可以得出另外个结论,就是用d加密,e解密也是成立的, c = m^d % n 能够推导出 m = c^e % n
- 私钥加密:c = m^d % n = 7 ^ 37 % 77 = 28
- 公钥解密:m = c^e % n = 28 ^ 13 % 77 = 7
所以这里也解答了我们的一个疑问:“我们经常听到的是公钥加密消息,私钥解密获得原文,那么能不能反过来”,答案是可以的,而且使用的算法还是一样的!
RSA的格式
前面我们已经了解到RSA最主要的就是三个数字[ n, e, d ],然后公钥持有[n, e],私钥持有[n, d]。
// 生成私钥
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
// 生成公钥
openssl rsa -pubout -in private_key.pem -out public_key.pem
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr5wW1OSlYTlIMrOAsIEP
nFjbd2YBAVSTjDJb/mBjA2lpI8GHX1ygNuw/kLxZ6kA+17oeHru+88XOmjOC/RxA
+3ak8w203t3SJHoYezQVjkz8bOLaPj20xzf0TnQIkn3pJSh/0lEjRzCrZTKhNd/w
5So1qTnsKlweFgWa6I8h9SnYzZ7x6Rn2gDrpKg5O2zFXfN3ixP8PL2UI4L6n+Wn3
c+0LgiOM4viCWDZlte1KxIXIMDdp57Y4cZCEwxiYOb6H34ao5oN2YAjE9Mljlztd
o2K4Mm6fPSOm2gG8UjHx9Fx6gCNT6ZlzhGaCG/7MtcytjYvdrIYjAGENJBtYlZ7c
jQIDAQAB
-----END PUBLIC KEY-----
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCvnBbU5KVhOUgy
s4CwgQ+cWNt3ZgEBVJOMMlv+YGMDaWkjwYdfXKA27D+QvFnqQD7Xuh4eu77zxc6a
... 省略部分内容
6iFYdAvFbAdewR/QqqDuY630De
D7XE+RIkhIAYvV01Qyv0P8f3yymBdOrjjLlPPN/Kz/mh9IhHZZ59YG8Y+56j8sXZ
cf3dHJd2xUxOfthq/wDE43AY
-----END PRIVATE KEY-----
生成公私钥后,可以看出其内容并不是简单的数字,而是一长串文本内容。那么[n,e,d]在哪里呢,可以通过工具或者熟悉的编程语言,将其解析出来
那[n,e,d]是怎么从简单的几个数字一步步变成我们看到的内容的,有了基础内容后,其实就是怎么定义数据结构,比如公钥有两个数字n和e,没有进行变量定义就区分不了。比如用json表示,{"n":77,e":13}。那么RSA采用的结构化表示是 ASN.1(Abstract Syntax Notation One),有点类似于我们编程定义的结构体,然后new对象把内容放进去
// 显示出 ASN.1 格式的编码内容以及它们的详细结构,包括标签、长度和数据值。
openssl asn1parse -in public_key.pem -inform PEM
// 显示偏移19后具体的值
openssl asn1parse -in public_key.pem -inform PEM -strparse 19
为了确保数据在不同系统和平台之间的互操作性和一致性,节省存储空间和传输带宽,不会直接用这段ANS.1的文本内容,而是进行DER编码变成一个紧凑的二进制格式的数据(DER 是 ASN.1(Abstract Syntax Notation One)的一种编码规则)
openssl rsa -pubin -inform PEM -outform DER -in public_key.pem -out public_key.der
因为是二进制,所以无法正常显示,所以会再对其进行base64编码,这样就能正常的明文显示,这段内容就是公钥文件中间的那串去掉换行后的内容了。
有了这段内容后,还有一个问题要解决,就是怎么知道内容是属于公钥还是私钥。那其实很简单,在内容外加一个说明就行了。就是我们现在看到的,在开头和结尾添加 "-----BEGIN ..." 和 "-----END ..." 标识符,将数据分割成固定长度的行,这就是PEM格式了。
那么公钥我们看到的开头是 -----BEGIN PUBLIC KEY-----
,有时候我们也会看到这种 -----BEGIN RSA PUBLIC KEY-----
多了一个RSA字符串。其实这表示的是公钥的不同数据结构格式(PKC#1和PKC#8),RSA刚开始使用的的ANS.1数据结构是PKC#1(PEM带了RSA的),比较局限,不能用于其它类型的密钥(DSA 密钥、CC 密钥等)表示,所以有了后面的PKC#8通用的结构。这就好比我们开发需求,一开始设计的数据结构只为当前的需求服务,到后面发现其它需求都有相似的逻辑,所以重构成一种新的数据结构解决更通用的问题。
RSA的使用
- 非对称加解密
- 对原文使用私钥加密,使用公钥解密(规范推荐)
- 原文使用公钥加密,使用私钥解密
从前面原理我们可以知道,RSA就是提供了一个数据加解密功能,那么我们是不是不要用AES这种对称加密算法,毕竟它有传输安全问题。然而事实上,我们很少使用RSA来做消息原文的加密,而且不推荐,因为这个加密/解密过程很耗CPU。从加解密过程,我们可以知道,都是指数级运算(c = m^e % n ),而且指数还很大,加密原文换成2进制也是很大的,做指数运算相当占CPU时间。如果你只是处理几个数据那还好,但在互联网时代,用https请求已经是一个基本的要求,建立连接过程就涉及RSA,当流量的规模扩大,一般机器是扛不住的。
还记得我司在虚拟机时代,要上Https功能,还专门去调研卸载Https转Http的设备,得有专门的机器来搞。就像防火墙也有专门的设备F5。
所以,我们很少看到RSA去做消息原文的加密,更多是做数字签名。
- 数字签名
数字签名是用于验证消息真实性和完整性的加密技术。它是一种加密机制,用发送者的私钥对消息进行加密,然后接收者可以使用发送者的公钥对签名进行解密验证。类似于我们经常用的 md5 / sha256 哈希函数,生成一个签名验证。
但是通过RSA的原理我们已经知道了,RSA本身只有加解密的能力,并不具备生成一个不可逆的签名值。那这是怎么回事呢?这其实是结合了哈希算法生成不可逆值能力和RSA传输安全的能力,也就是说一个算法封装了哈希和RSA,定义好了签名验证的标准,各个语言去实现就行了,常见指定的签名类型为 sha256-RSA
RSA 数字签名通常使用的算法是 RSASSA-PKCS1-v1_5,它是一种基于 RSA 加密算法的签名方案。这个方案通常使用SHA-1、SHA-256等哈希算法作为摘要算法。
RSASSA-PKCS1-v1_5 算法包含以下步骤:
- 哈希计算:首先对消息进行哈希运算,通常使用 SHA-1、SHA-256 等哈希算法生成消息的摘要。
- 填充:对摘要进行填充,以确保其长度符合 RSA 加密算法的要求。
- 数字签名:使用私钥对填充后的哈希值进行加密,生成数字签名。
- 验证签名:接收方使用发送方的公钥对签名进行解密,并对接收到的消息进行相同的哈希运算。如果两个哈希值相同,则验证通过。
RSA证书与证书链
如果交互的双方都是已经明确的对象了,比如生成了一对公私钥,王纸把公钥交给公主,公主是已经确认给公钥的就是王纸本人,而不是伪装的,那公主和王纸彼此就可以安全的通信,不用担心伪造的问题。
但是如果王纸,是通过飞鸽传书把公钥给到公主,那路上就可能被坏人截获,把密钥换成自己的,而公主并不知道被偷换了,那么之后公主一直通信的对象就是坏人了,这就是所谓的中间人攻击。
所以公主需要确保公钥本身就是王纸的,才能用它来通信,比如除了公钥还配了王纸的家庭地址,并且防止伪造,还基于公钥和家庭地址,用私钥生成了一个数字签名,但这样还不够,因为家庭地址是公开的信息,一样可以用其它公钥钥生成一个。那问题就是无法自证,所以需要第三方来作证,是否是真实的。比如由民政局的私钥来生成一个证书,民政局会确认家庭地址里住的就是王纸本人,把王纸的公钥和家庭地址和数字签名放在了一起,然后王纸就把民政局发的证书给到公主,公主就会用民政局的公钥验证数字签名,如果能验证通过,就说明证书里的公钥是王纸的。
从中,我们可以发现跟RSA相关的概念就是 证书 和 第三方机构(CA), 而且证书包含公钥本身、证明(网站域名,好比流程图里的家庭地址)、数字签名、有效期、序列号主要的关键信息。
比如37手游的一个证书信息(证书都是公开的,浏览器就可以下载)证书查看工具
证书本身还会分 CSR(证书签名请求) 和 CA签发证书,以及证书链。
也就是说要申请CA签发数字证书,首先你要先基于私钥文件生成一个csr文件,类比前面流程图里的第一步申请书,填写要求输入的信息即可。然后把CSR证书以及要授权的域名提交给CA,CA会检查域名进行确认,证明域名所有者对该域名的控制权。
// 生成CSR
openssl req -new -key private_key.pem -out csr.pem
-----BEGIN CERTIFICATE REQUEST-----
MIICijCCAXICAQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx
ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcN
... 省略部分内容
C3i8NjfaK2dqbfTafxbGYtVzhCftHyXCN4W185dXnmKp3mIZhhmHx7dDGFs34bYD
8Hvfw2FABxbECiSbItx7SPa8G122i8NvSb0ND6Ux
-----END CERTIFICATE REQUEST-----
从CA申请得到签发数字证书内容(从浏览器导出的证书文件)
-----BEGIN CERTIFICATE-----
MIIHjjCCBXagAwIBAgIQDt8EaIkvlxL/bLTQD3YDWzANBgkqhkiG9w0BAQsFADBc
MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xNDAyBgNVBAMT
... 省略部分内容
qGv5OpCwEilodviHQ1nnvB6GOfY4jQupWYCbhYRFQ2WdUYTxABfi4WhI5fCW9g7K
C9FkZvV/ZlkdjMgyk+NBT/Cc
-----END CERTIFICATE-----
可以看出CSR和数字证书都是PEM格式的文本内容,CSR开头是-----BEGIN CERTIFICATE REQUEST-----
而数字证书是-----BEGIN CERTIFICATE-----
如果是只有CA这层,那你就会拿到一个根证书(1份)。但是为了方便管理,CA本身会授权其它机构做签发证书,形成一个证书链,所以一般情况我们拿到的就是其它机构的签发证书,以及CA签发给机构的根证书(两份)。类比前面流程图,民政局不可能处理那么多事情,就会授权下一级机构,比如居委会。
如果是签发证书链的话,都需要把它放在服务器上,比如浏览器https交互过程会拿到服务器所有证书,组成证书链,逐一往上检查整个链条是否正确的。当然如果服务器上没放全,只放了其它机构的签发证书,那如果客户端不主动去找根证书,那么就会建立连接失败。
证书验证过程,比较关键就是验证证书本身是有效的,也就是公钥是可信任的,并且证书里的域名跟当前访问的域名是一致的或者是其子域名,这样保障校验"我"就是"我"的。
解答
我们再看下还没有解答的 对接遇到的问题
- 对接方提供的配置是证书,开发不熟悉,把公钥和证书混淆;
从证书的开头进行区分,如果是-----BEGIN PUBLIC KEY-----
或者-----BEGIN RSA PUBLIC KEY-----
那就是公钥了,如果是-----BEGIN CERTIFICATE-----
那就要额外再解析出公钥才能用。
- 验证签名要根据接口参数的证书序列号找到对应的证书进行验证(一个接口会有不同私钥生成签名的情况);
从证书一节,可以知道数字证书可以解析出很多信息,其中就包括序列号,所以会有后台专门去配置序列号对应的证书、公钥,这样子代码执行过程就可以通过序列号找到对应的公钥进行签名验证
- 仿照Https通信,接口参数携带自签证书,要求构建证书链验证签名
从证书一节,可以知道数字证书包含了对证书内容的签名,那么我们验证签名,其实就是用对应的第一级验证证书检验证书的签名是否有效,然后再用上一级的根证书验证第一级的签名是否有效。
工具:
PHP代码指定p和q,生成公钥[n, e], 私钥[n, d]
<?php
// 已知的值
$p = 7;
$q = 9;
$n = $p * $q;
$phi = ($p-1)*($q-1);
$data = findCoprime($phi);
echo "p: {$p}". PHP_EOL;
echo "q: {$q}". PHP_EOL;
echo "n: {$n}". PHP_EOL;
echo "ϕ(n): {$phi}". PHP_EOL;
foreach($data as $val) {
echo '--- 符合的 -----'. PHP_EOL;
echo "e: {$val}". PHP_EOL;
$result = extendedEuclidean($val, $phi);
list($x, $y, $gcd) = $result;
if ($gcd == 1) {
$d = $x % $phi;
if ($d < 0) {
$d += $phi; // 确保 d 是正数
}
echo "d: $d" . PHP_EOL;
} else {
echo "无解" . PHP_EOL;
}
}
function extendedEuclidean($a, $b) {
$x = 0; $y = 1; $lastX = 1; $lastY = 0; $temp;
while ($b != 0) {
$quotient = intdiv($a, $b);
$temp = $a % $b;
$a = $b;
$b = $temp;
$temp = $x;
$x = $lastX - $quotient * $x;
$lastX = $temp;
$temp = $y;
$y = $lastY - $quotient * $y;
$lastY = $temp;
}
return [$lastX, $lastY, $a];
}
// 与n互质的最小数
function gcd($a, $b) {
while ($b != 0) {
$temp = $a % $b;
$a = $b;
$b = $temp;
}
return $a;
}
function findCoprime($n) {
$data = [];
for ($i = 4; $i < $n; $i++) {
if (gcd($i, $n) == 1) {
$data[] = $i;
}
}
return $data;
}
测试加解密,因为涉及指数计算生成大整数,要使用专门的库处理
package main
import (
"fmt"
"math/big"
)
func main() {
encrypt(13, 37, 77, 7)
encrypt(37, 13, 77, 7)
}
func encrypt(e, d, n int64, m int64) {
// 创建大整数 M, e, N
M := big.NewInt(m)
E := big.NewInt(e)
N := big.NewInt(n)
D := big.NewInt(d)
// 创建一个用于存储结果的的大整数
result := big.NewInt(0)
// 执行幂运算并取模
result.Exp(M, E, N)
fmt.Println("加密:", result) // 输出结果
deRes := big.NewInt(0)
deRes.Exp(result, D, N)
fmt.Println("解密:", deRes) // 输出结果
if m == deRes.Int64() {
fmt.Println("解密成功")
} else {
fmt.Println("解密失败")
}
println()
}