Skip to content

什么是RSA

简单地说,RSA就是一种非对称加密算法,它组成了今天计算机网络安全的基石。每个人的浏览、消息、支付等背后,都有RSA的身影。虽然其有很多竞争者,但直到如今,RSA依然是主流的非对称加密方式。比如B站的证书就是基于RSA的。

本文旨在让已经对非对称加密有一定了解的朋友了解RSA所使用的算法。如果你不了解非对称加密,本文并不能作为非对称加密的科普,可以去网上搜索看看其他的非对称加密相关的介绍。

RSA中使用到了许多定理与一些为被证明的假设,本文仅使用这些定理与假设,不深究其具体细节与证明。

函数与运算符号

部分函数虽然小学就学过了,但我们可能对它们的名字比较陌生,先看看这些函数的名字。

  1. :计算m与n的最大公因数。
  2. :计算m与n的最小公倍数。
  3. :计算m除n的余数,也称作m模n,前者在数学中出现,后者在计算机程序中出现。
  4. : a除c的余数等于b除c的余数,也被称作a与b模c同余。其中恒等号可换为等号。

如何生成一个RSA密钥?

  1. 生成两个质数 p 和 q。

    p和q应该是随机的,并且差距应该足够大。

  2. 计算

  3. 计算

  4. 选择一个数e,使得e与λ(n)互质。这里e是公钥指数。

  5. 根据e与λ(n),计算私钥指数d,满足

经历过这些步骤,我们得到了RSA加解密所需要的全部信息,将(N, d)作为私钥保存,而(N, e)作为公钥发布,而其中的p、q、λ(n) 均无需保留,可以抛弃。不过在大多数的RSA实现中,p和q都被保留在私钥文件中。

算法演示

我们使用一个较小的数来演示生成RSA密钥的方式。按照上面提到的方法生成一个演示密钥。

  1. 选择p=257,q=379.
  2. N = 257 * 379 = 97403.
  3. λ(N) = lcm(256, 378) = 48384.
  4. 选择e = 5.
  5. 计算得到d = 9677.

可以验证,(5*9677) mod 48384 = 1.

其实这里我想选e=3,发现3居然是48384的因子,遂选择5作为公钥指数。

组装公钥E为(N = 97403, e = 5),而私钥D为(N = 97403, d = 9677)。

公钥加密

当Bob持有Alice的公钥E时,如果它想给Alice发送一个秘密消息msg,需要先将m转换为一个不大于N的数字m,加密方法为计算 ,此时c就是密文,可以通过不安全的信道传送给Alice。

假设Bob要发送“hi”,那么可以将“hi”两个字母中的ASCII编码,计算可得“hi” = 104*256+105*1 = 26729,接下来算,此时49388即可发送给Alice。

私钥解密

当Alice接收到Bob的密文c时,Alice可以使用他的私钥解密。解密方法为计算

计算得到 ,按照拼接ASCII的反向算法就可以得到消息msg为“hi”了。

解决消息过长

上面的例子中,Bob给Alice发送的消息仅有两个ascii字符,如果有三个,那计算得到的结果会超出N,虽然依然可以算,但是解密后的消息就不是原消息M了。比如Bob发送了消息“ack”,编码后为“ack” = 97*256**2+99*256+107 = 6382443. 继续计算密文得到c=6473,而解密后得到m为51248,和原消息不同。

解决方案非常简单:将消息分段,使每段可以满足m<N,再将分段一起发送或保存即可。

而实际应用中,非对称加密常被用于传递对称加密的密钥。RSA密钥中的N往往有2048bits或者更高的值,使用它来传递一个通常只有256bits的对称密钥是无需分段的。

密钥长度

密钥长度就是平时所谓的密钥的位数,比如256位AES就是说AES的密钥长度是256位。而RSA的密钥长度指的就是N的长度,即N有多少比特,或者说是N在二进制下的位数。

比如上文演示用的密钥N为97403,二进制为“10111110001111011”,即该密钥的位数为17位。目前被认为较为安全的是2048位及以上的密钥长度。

安全性

实践中保证RSA的安全性,需要很多细节工作。这里我们只讨论理想条件下,增加密钥长度是如何增加安全性的。从上面算法演示的过程中,我们知道,如果分解了N=p*q,那么相当于破解者也可以从第一步开始算得私钥指数d,此时该密钥对也就被破解了。目前,最行之有效的分解大数的方式是 Pollard rho算法,对于一个合数M,若其因子包括,那么其期望的时间复杂度为,而在实际的分解中,其时间复杂度往往能达到. 对于大数N=p*q 而言,其期望的时间复杂度为。对于2048bits的RSA密钥来说,其消耗时间依然高达,底线在上,t为一个常数,所以目前在传统计算领域是安全的。

要注意的是,p与q不能过于接近,否则可以先计算,然后从n开始做减法计算因子。这种分解方法叫做费马因式分解。从上面的时间复杂度分析上我们可以知道,只要保证即可让费马因式分解的期望时间长于Pollard rho算法。

量子安全性

秀尔算法(Shor's algorithm)和量子计算机的出现似乎打破了RSA的安全神话。这项技术为因数分解提供了指数级加速,足以在多项式时间内分解N,然而其需要量子计算机有与RSA密钥位数相当的量子比特,而量子比特的增加是非常困难的。目前已知的具有最多量子比特的量子计算机为IBM Eagle(2021年),其拥有127个量子比特。

目前公开的对RSA的最佳量子破解也来自IBM,他们使用量子计算机成功分解了15=5×3.

不能被证明的安全性

事实上,RSA安全性的核心,也就是因数分解的困难性,并没有被证明。该问题是一个NP问题,可以在多项式时间内验证p*q=N,但不能在多项式时间内算出N的两个因子p和q.

P=NP这个问题至今没有答案,但RSA等非对称加密手段都是建立在P!=NP的基础之上的,由于没有人能拿出一个行之有效的破解方式,所以大家相信破解RSA是不可能的. 值得一提的是,一个调查显示,知道NP问题的人中,多数人相信P!=NP.

当然,即使哪天证明了P=NP,如果只有证明而拿不出有效的算法的话,我们或许依然可以焦虑地使用RSA等非对称加密手段.

工程实现

RSA生成的5个步骤中,第2,3,4步都很简单,然而第1步和第5步则需要特殊的算法实现。

本文会提供切实可行的实现方式,要求如下。

Windows 10及以上操作系统,Python >= 3.7,Python无需安装第三方库。

选择Python是因为其原生提供无限长度的整数运算,无需额外关注具体的大整数运算实现,同时具有较好的可读性。

对不了解Python的朋友,我在下方简单的介绍一下下文中使用的功能。

  • a ** b: 计算a的b次方,等于.
  • pow(a, b, m): 计算a的b次方模m,等于.
  • public_key = N, e: 将N和e”打包“进变量public_key中。
  • N, e = public_key: 将N和e从变量”public_key“中解包出来。
  • for var in range(n):循环n次,var会从0赋值到n-1,不关心var的值时可以使用 _代替。
  • random.randint(m, n): 生成从m到n的整数,包括m和n.

Miller-Rabin 算法

演示中使用了两个小质数257和379,这两个数即使是使用纸笔的人类也能验证其并非合数。然而在实际应用中,我们面对的是这种数量级的质数,仅凭试除法肯定是是不能判断其是否为质数的。此时就需要一个多项式时间的算法来判断该数是否为质数,也就是米勒-拉宾素性检验算法(Miller-Rabin primality test)。

米勒拉宾算法是一种概率性的检验算法,其并不能绝对判断某数是否为质数。但是在合理选择迭代次数的情况下,其错误率是可接受的。下面给出Miller-Rabin算法的Python实现。

python
def miller_rabin(n, k):
    # n必须为一个奇数
    if n % 2 == 0:
        return "composite"
    s = 0
    m = n - 1
    if m % 2 == 0:
        s += 1
        m //= 2
    d = (n - 1) // (2 ** s)
    # 找到 s > 0, d > 0 且 d 为奇数, 使得 2 ** s * d = n - 1
    # 由于 n 为奇数,所以 s 至少为 1,同时当 s 足够大时总能找到一个奇数 d 满足上面等式
    for _ in range(k):
        a = random.randint(2, n - 2)
        x = pow(a, d, n)  # 快速幂取余,相当于 (a ** d) % n
        # 定理1. 如果 n 是一个质数,对任意整数 x,当且仅当 x = 1或x = -1时,(x ** 2) % n = 1
        # 定理2. 如果 n 是一个质数,对任意整数 a,总有 a ** ((2 ** s) * d) = 1  (mod n)

        for _ in range(s - 1):
            x = pow(x, 2, n)
            if x == 1:
                return "composite"
            if x == n - 1:
                break
        if x != n - 1:
            return "composite"
    return "probably prime"

Miller-Rabin 算法给出了一个时间复杂度为的算法,它能有效的检查一个数是否为质数,其中k为循环次数,log来自于快速幂取余的时间复杂度。而其将一个合数误报为质数的概率为,因此很容易将在线性时间内以足够高的准确度预测n是否为一个质数。OpenSSL中,k默认为20.

质数分布与大质数生成

一个显而易见的事实是,除了2和3,质数n必然满足 ,其中k为正整数。

当生成一个n bits的质数时,实际上是在中找一个整数,验证是否为质数。

由于质数分布规律较为复杂,其生成时间具有较大的不确定性。不过,根据质数定理,对于一个自然数N,其前面的质数大约有 个。

那么,如果生成1024bits的质数,我们可以算得在质数的密度如下。

可以看到,平均而言,每543.12个数中有一个质数。这个数字不是很大,所以即使暴力搜索也能比较快的搜索到。下面是具体的实现代码。

python
def gen_prime(n):
    # n为要生成质数的位数
    num = random.randint(2 ** (n - 1), (2 ** n) - 1)
    num = ((num // 6) * 6) + 1  # 6n+1 模式
    # 这里设置为5,速度会快很多,安全性上应该设置为较大的值,比如15以上
    while miller_rabin(num, 5) != "probably prime":
        # 如果不是质数,那就再生成一个
        num += 4  # 6n + 5 模式
        if miller_rabin(num, 5) == "probably prime":
            break
        num += 2  # 6(n+1) + 1 模式
    return num

测试CPU为i7-7700HQ,生成1024bits的质数,测试20次,平均消耗31.14秒。实际平均约10000个奇数遇到一个质数。和上面的计算相差较大,暂未知原因。

扩展欧几里得算法

第五步中,要求计算 de = 1 (mod λ(N)). 即计算e模λ(n)的乘法逆元。

变形这个算式,可以得到 ,其中d, e, k 均为正整数,e为已知数。

当然,可以从k=1开始,一个个计算符合的d,不过有更快的算法,也就是标题提到的扩展欧几里得算法。

扩展欧几里得算法,顾名思义,其由欧几里得算法扩展而来,而欧几里得算法就是辗转相除法。

而扩展欧几里得算法,则可以求解裴蜀等式(或贝祖等式)

求解裴蜀等式就是当 a, b 已知时,求解一对可以使等式成立的x, y.

我们将求解乘法逆元的算式代入,得到下面的式子。

接下来就可以使用扩展欧几里得算法求解d了。

python
def ex_gcd(a, b):
    if a == 0:
        return b, 0, 1
    else:
        gcd, x, y = ex_gcd(b % a, a)
        return gcd, y - (b // a) * x, x

简单地说,ex_gcd返回的第二个值就是要求的私钥指数d.

自己生成一个密钥对

上文中给出了生成一个密钥对所需的全部算法。接下来我们可以自己生成一个可以使用的密钥对了。

python
def fmt_big_int(name, num):
    max_len = 30
    name += " = "
    indent = len(name)
    num = str(num)
    if len(num) <= max_len:
        return f"{name}{num}"
    num_l = []
    for idx in range(0, len(num), max_len):
        num_l.append(num[idx:idx + max_len])
    return name + " \\\n{}".format(' ' * indent).join(num_l)


def generate_key_pair():
    bits = 1024
    p = gen_prime(bits)
    q = gen_prime(bits)
    N = p * q
    lambda_N = math.lcm(p - 1, q - 1)
    e = 65537  # 通常e会选择65537,这几乎不会和lambda_N有公因数
    gcd_e = math.gcd(e, lambda_N)
    if gcd_e != 1:
        print(f"真不巧e和λ(n)有公因数{gcd_e}")
        return
    _, d, _ = ex_gcd(e, lambda_N)
    if d < 0:  # 可能算出负数来,算出来了就加上,避免私钥指数为负数。
        d += lambda_N

    print(fmt_big_int("N", N))
    print(fmt_big_int("p", p))
    print(fmt_big_int("q", q))
    print(fmt_big_int("e", e))
    print(fmt_big_int("d", d))

下面是我生成的密钥对。

python
N = 227442712852228682420414779319 \
    562687651643739341662796692929 \
    163922400267934787590621116694 \
    891760619327203004901144906832 \
    800095526049668470542778999323 \
    651897322671119077281968001372 \
    446392959926682893287212273771 \
    241722129942437331580897662308 \
    578666249068303886306662169412 \
    193217352311366461532975812852 \
    124193228404641158708725065274 \
    708644109118939811612576529812 \
    846988041909580026981490541567 \
    445743128463667379869830312310 \
    386152013370124058632684548068 \
    516907871220638110433917758192 \
    056516147025352893608425414982 \
    659378237947057838512573683719 \
    656010305302908753593858876575 \
    962355149895528849120178912224 \
    35170357514424413
p = 145051038077958860693796012628 \
    625876634665931949940050370449 \
    387104965384223153152471552741 \
    833745166885914511061893077670 \
    464028682608703752485689595048 \
    619321249150617970591218334362 \
    527527974241212468108567074763 \
    798879903619803151999405468786 \
    884736416473525334019578812862 \
    866293752484163777592143766285 \
    811404063
q = 156801851173231690833526980220 \
    070443094216022723233530479768 \
    612945390163310468787991953168 \
    708616715527769639602060536294 \
    556776062794179272183786104176 \
    586288917546573301530873029141 \
    344273752018571280068748133198 \
    532396742562461156175632492260 \
    503161905280147990066527029768 \
    573516310751136244023374550179 \
    197384451
e = 65537
d = 190701085970214780948194029687 \
    199134633227390280671539409439 \
    821132122232067634742277345047 \
    596048736317344478986189673473 \
    951588402832434845298468132701 \
    140909072444237503954165458831 \
    132479258250625219130145024089 \
    136405862952788979818580748948 \
    783095204026783321674032778570 \
    883886865579895128877382561243 \
    636791701404807498474376829463 \
    986075399201337437286982733483 \
    883375774171899112093369852188 \
    022751835849225142095728136344 \
    556634868177611264216881972938 \
    769052035843741360883732498917 \
    623652545912814027557437888976 \
    645651446879691031770018258055 \
    933872022471957950567451266956 \
    588963326221579878715175692341 \
    743003401618123

在实践中,不要使用文章里的方式生成密钥,这只是作为演示使用。虽然文章完整的复现了RSA的生成算法,但是很多地方并没有考虑。比如没有检查p和q是否过分相近,Miller-Rabin检查次数过少,没有使用安全的随机数生成器等等,它虽然是一个可用的RSA密钥对,但是并不具有足够的安全性。

尝试使用自制的密钥对进行加解密

现在我们已经具备生成一个RSA密钥对的能力了,可以尝试使用生成的密钥对进行数据的加解密。

注意:这种加解密方式是不安全的 ,不要在实践中使用这种方式。

python
def big_int_to_bytes(num):
    out = []
    while num > 0:
        out.append(num % 256)
        num //= 256
    return bytes(out)


def bytes_to_big_int(content: bytes):
    s = 0
    for i, byte in enumerate(content):
        s += byte * (256 ** i)
    return s


def encrypt(public_key, msg: bytes):
    N, e = public_key
    msg = bytes_to_big_int(msg)
    if msg > N:
        raise ValueError("msg过长,无法编码")
    cipher_text = pow(msg, e, N)
    return big_int_to_bytes(cipher_text)


def decrypt(private_key, cipher_text: bytes):
    N, d = private_key
    cipher_text = bytes_to_big_int(cipher_text)
    if cipher_text > N:
        raise ValueError("密文过长,无法解密,可能不是密文。")
    plain_text = pow(cipher_text, d, N)
    return big_int_to_bytes(plain_text)


def test_rsa():
    N = 227442...4413 # 真实数字在上面,这里太长就不放了
    e = 65537
    d = 1907...123
    msg = b"Hello, I'm Bob."
    print("msg = ", msg)
    cipher_text = encrypt((N, e), msg)
    print("cipher_text = ", cipher_text)
    decrypted_text = decrypt((N, d), cipher_text)
    print("decrypted_text = ", decrypted_text)

加密与解密的结果

python
msg =  b"Hello, I'm Bob."
cipher_text =  b'\x0f\x9b\t...@\xc5\xadc\x97\x9a' 
# 加密的密文比较长,就不全写了,可以自己复制测试
decrypted_text =  b"Hello, I'm Bob."

RSA特性

从上面的算法讲解,可以发现RSA有很多特性。

公钥与私钥的对称性

上文中,我们并没有对公钥指数e做出什么特殊要求,仅仅是看似随机的选取了65537这个数,它并没有成为公钥指数的必然性。而在加密和解密章节中,这种对称性则更加明显,除了变量名和报错信息有区别,它们完全是一样的。

任何与λ(n)互质的数都可以作为公钥e。而解密密钥d则是使用扩展欧几里得算法得到的。回到求解私钥的要求中。

可以看到公钥与私钥时对称的,当把选取的数字作为私钥指数时,那么计算得到的就是公钥指数了。事实上,RSA三人组最初的论文中,选取的数字就是作为私钥指数,而计算得到的则为公钥指数。

但是,将选取的数字作为公钥具有一定的好处。选择一个很小的数字作为公钥指数,私钥指数就会非常大,这为破解增加了难度。

交换公私钥的安全问题

假如以d作为公钥指数,那么私钥指数e就只有65537了,此时如果知道d和N,就可以对e进行暴力破解。下面是对交换公私钥后成功的暴力破解。

python
def crack_unsafe_private_key(public_key, cipher_text):
    # 假设 Alice 加密的是以ASCII编码的明文
    N, e = public_key
    cipher_text = bytes_to_big_int(cipher_text)
    for d in range(3, 100000):
        probably_plain_text = pow(cipher_text, d, N)
        probably_plain_text = big_int_to_bytes(probably_plain_text)
        if probably_plain_text.isascii():
            print("破解出明文可能是: ", probably_plain_text, "d = ", d)
            return


def test_crack():
    # 对当前的公钥选择策略而言,交换公私钥后,其不能保证安全
    N = 227442...4413
    e = 65537  # e仅作为展示,此处假设它是隐藏的私钥指数
    d = 190...123 #让算出来的d作为公钥,此时不仅加密速度变慢,而且会不安全
    # 这里反向利用,将计算出来的d作为公钥指数,而e作为私钥指数
    cipher_text = encrypt((N, d), b"Hello, I'm Alice.")
    crack_unsafe_private_key((N, d), cipher_text)

运行结果如下

破解出明文可能是:  b"Hello, I'm Alice."  d =  65537

可以看到,2048bits的密钥很容易就被破解了。所以虽然公私钥具有对称性,但是并不能真的将随便一个数作为私钥指数。使用私钥加密,公钥解密并不能保证秘密消息的安全性。

数字签名

然而私钥加密依然有其用武之地。只有使用私钥加密的信息,其他人才可以使用公钥解密。利用这个特性,Alice可以将其要公布的明文信息与该信息被私钥加密的密文一起公布,这样任何持有Alice公钥的人都可以验证消息来自Alice。Alice的动作叫做签名(Sign),而验证者Bob的动作叫做验正(Verify)。

python
def sign_and_verify():
    N = 2274427...413
    e = 65537
    d = 190...123
    msg = b"Hello, I'm Alice"
    cipher_text = encrypt((N, d), msg)
    print("msg = ", msg, "cipher_text = ", cipher_text[:10] + b"...")
    # 验证签名
    decrypted = decrypt((N, e), cipher_text)
    print("decrypted = ", decrypted)
    print("is decrypted == msg :", decrypted == msg)

函数输出如下。

msg =  b"Hello, I'm Alice" cipher_text =  b'\xd0@;2\x995X\x7f\x1e\x94...'
decrypted =  b"Hello, I'm Alice"
is decrypted == msg : True

而如果有人篡改了明文或者篡改了密文,那么解密后的密文与明文会不同,也就验证消息没有来自Alice。

在实际应用中,文件通常很大,通常总会超过密钥能加密的最长长度。而分段虽然可以,但是需要下载两个内容完全一样的文件,消耗一倍的流量。所以实践中,会先对文件求一个加密哈希(如SHA-256),然后对哈希值做加密,这样签名的体积就会小很多。验证者只要解密出哈希后,再对原文件做哈希,比对两个哈希值是否相等即可

明文填充

可以看到,如果我们把RSA明文转换为正整数加密,那么1显然是无法被加密的。为了避免这种情况以及增加安全性,出现了明文填充 (Padding)。Padding 的目的为尽可能提高安全性,以及提供验证消息是否被正确解密的方法。选择一个好的Padding可以增强RSA的安全性。

比如在交换公私钥的安全问题提到的暴力破解中,其实只要解码出任意一个字符是非ASCII字符即可跳过,这提高了破解速度,而一个好的Padding,必须要解码全部消息才能够验证消息是否被正确解密。

不过Padding不在本文的讨论范围内。

实践中使用RSA

这篇文章主要介绍RSA的实现及其算法,不会对如何使用RSA工具做赘述。但本文可以提供一些工具的简单介绍。

下面这些工具很多不止提供基于RSA的非对称加密,也有基于其他算法的非对称加密。

OpenSSL

这是世界上最主流的非对称加密软件,内置在许多操作系统中,Chrome浏览器完成HTTPS使用的也是OpenSSL的一个分支版本BoringSSL。

下面的命令可以在Windows上使用(PowerShell),应该也适用于Linux及Mac。

bash
# 生成一个1024bits的质数
openssl prime -generate -bits 1024
# 生成一个2048bits(3.1.10 默认)的RSA密钥
openssl genrsa 2048
# 生成一个2048bits rsa密钥,并以人类可理解的方式打印
openssl genrsa 2048 | openssl rsa -text

可以看到第三个命令打印了了 exponent1 和 exponent2,以及coefficient三个本文没有介绍过的值。

这三个值是从私钥计算而来,目的是加快私钥解密速度,并不对RSA算法起实际作用,删去它们不会对算法的正确执行造成任何影响,只是会慢一些。

PGP

PGP原本是一个使用非对称加密的商业应用程序,但随着时间进展,其已经变成了一套标准,被称作OpenPGP,有很多符合该标准的开源软件。

Windows平台有Gpg4win,这是一套软件,可以提供邮件、文件的加密与验证。

Android有OpenKeyChain,也可以较为简单的使用。

在线版本

也有一些网页提供了网页版RSA加解密,比如这个网站就提供了RSA生成与加解密的功能。

最后更新: