为后量子时代做准备:格密码学入门指南

1. 引言

保护互联网安全的密码学正在演进,现在是跟上变化的时候了。本文是一篇格密码学教程;格密码正是后量子(PQ)迁移的核心范式。

十二年前(2013 年),美国大规模监控计划遭曝光,推动了 TLS 在 Web 加密和身份认证中的广泛采用。基于椭圆曲线的新型高效公钥密码方案完成标准化并投入实现,也为这次转型提供了助力。与此前的方案------包括 RSA 和有限域上的 Diffie--Hellman------相比,椭圆曲线密码不仅速度更快,通信量也更小。

今天向后量子密码学的迁移,是为了应对一个正在逼近、影响 TLS 乃至更多系统的威胁:一旦足够大的量子计算机建成,它就能破解目前使用的所有公钥密码。而量子计算机工程仍在不断取得进展,使这一威胁离现实越来越近。

幸运的是,这场迁移已经全面展开。过去数年里,研究界和标准组织一直在开发能够抵抗量子密码分析的替代方案。Cloudflare 也参与了这一过程,并率先采用新开发的方案。事实上,自 2022 年起,Cloudflare 的边缘网络就已经提供 PQ 加密;截至 2025 年,它已应用于超过 35% 的非自动化 HTTPS 流量。2025年,Cloudflare 开始大力推动 TLS 生态系统采用 PQ 身份认证。

基于格的密码学将是首个取代椭圆曲线的范式。除了具备 PQ 安全性外,从 CPU 时间来看,格密码通常与椭圆曲线一样快,有时甚至更快。不过,这种新的公钥密码范式有一项主要代价:格密码需要远高于椭圆曲线的通信量。 如,使用格方案建立加密密钥时,客户端与服务器之间需要传输 2272 字节(ML-KEM-768);而采用现代椭圆曲线方案(X25519)交换密钥只需要 64 字节。适应这种开销需要大量工程工作,从处理 TCP 数据包分片,到重新设计 TLS 及其公钥基础设施。因此,PQ 迁移需要大量具有不同背景的人参与,而不只是密码学家。

本文的主要读者,是那些参与 PQ 迁移、并希望更深入理解其底层原理的人。不过,从更根本的角度看,每个人都应在一定程度上理解格密码学,特别是当准备把自身安全和隐私托付给它时。

假设本文读者具有软件工程背景,并熟悉 TLS、加密和身份认证等概念。接下来会看到,至少从最高层面来看,格密码背后的数学并不难掌握。具备密码工程背景、希望进一步深入的读者,可以从 Vadim Lyubashevsky 的优秀教程Basic Lattice Cryptography: The concepts behind Kyber (ML-KEM) and Dilithium (ML-DSA)开始,本文正是以该教程为基础。同时也推荐 Sophie Schmieg 关于这一主题的博客文章Learning, but with Errors

迁移到格密码虽然会带来成本,也会创造新的机会。许多可以用椭圆曲线构建的东西,同样可以用格构建,尽管效率不一定相同;此外,还有一些事情目前只有使用格才能高效完成。将在文章末尾简要介绍其中一些应用。

2. 加密

PQ 迁移最紧迫的问题,是确保明天的量子计算机无法破解今天的加密。攻击者现在可以保存你的笔记本电脑与所访问网站之间交换的数据包,并在未来某个时候借助量子计算机解密它们。这意味着,如今在互联网上传输的大量敏感信息------从 API 令牌和密码到数据库加密密钥------将来都可能被量子计算机解开。

事实上,今天 TLS 中的加密大部分 已经具备 PQ 安全性:真正面临风险的是浏览器与服务器建立加密密钥的过程。 目前,这通常依赖不具备 PQ 安全性的椭圆曲线方案。本节的目标,就是理解如何改用具备 PQ 安全性的格方案来完成密钥交换。

本文将逐步实现 ML-KEM(又名 Kyber)的简化版本;ML-KEM是目前部署最广泛的 PQ 密钥交换方案。本代码不如符合规范的生产级实现高效和安全,但足以帮助理解主要思想。

该代码起点,是一个与 Diffie--Hellman(DH)密钥交换极为相似的协议。对不熟悉 DH 的读者来说,它的目标是让 Alice 与 Bob 通过不安全的网络建立共享秘密。为此,双方各自选择一个随机秘密数,计算对应的"密钥份额",再把密钥份额发送给对方:

Alice 的秘密数是 s s s,她的密钥份额是 g s g^s gs;Bob 的秘密数是 r r r,他的密钥份额是 g r g^r gr。双方拿自己的秘密数和对方的密钥份额,都可以计算出 g r s g^{rs} grs。这个协议的安全性取决于如何选择 g g g、 s s s 和 r r r,以及如何进行算术运算。最高效的 DH 实现使用椭圆曲线。

在 ML-KEM 中,用矩阵 运算取代椭圆曲线上的运算。这并不是完全可以直接替换的,所以需要了解一点线性代数才能理解。不过别担心:在此会使用 Python,从而获得可以动手实验的可运行代码,并使用 NumPy 保持较高的抽象层次。

3. 所需的全部数学知识

矩阵就是一个二维数字数组。在 NumPy 中,可以这样创建矩阵(将 numpy 导入为 np):

python 复制代码
A = np.matrix([[1, 2, 3],
               [4, 5, 6],
               [7, 8, 9]])

这里定义的 A 是一个 3 × 3 3\times3 3×3 矩阵,其元素满足 A 0 , 0 = 1 A_{0,0}=1 A0,0=1、 A 0 , 1 = 2 A_{0,1}=2 A0,1=2、 A 0 , 2 = 3 A_{0,2}=3 A0,2=3、 A 1 , 0 = 4 A_{1,0}=4 A1,0=4,依此类推。

在本文中,矩阵元素始终为整数。此外,每当两个整数相加、相减或相乘后,都会像处理时钟上的小时数一样对结果进行约减 ,使结果落入 range(Q);这里的正数 Q Q Q 称为模数。具体数值现在并不重要,不过 ML-KEM 使用 Q = 3329 Q=3329 Q=3329,所以暂时采用这个值。(时钟算术的模数是 Q = 12 Q=12 Q=12。)

在 Python 中,整数 abQ 的乘法写作 c = a*b % Q。先计算 a*b,再将结果除以 Q,最后把余数赋给 c。如,42*1337 % Q 等于 2890,而不是 56154。模加法和模减法的处理方式类似。在本文后续内容中,如果上下文已明确表示模算术,有时会省略 % Q

接下来需要用到三种矩阵运算。

第一种是矩阵转置 ,在 NumPy 中写作 A.T。该操作沿矩阵的对角线翻转矩阵,因此对所有行 i i i 和列 j j j,都有 A j , i T = A i , j A^T_{j,i}=A_{i,j} Aj,iT=Ai,j:

python 复制代码
print(A.T)
# [[1 4 7]
#  [2 5 8]
#  [3 6 9]]

为了直观理解,可以想象把一个矩阵写在半透明纸上。从纸张左上角到右下角画一条线,然后让纸张绕这条线旋转 180 ∘ 180^\circ 180∘:

第二种是矩阵乘法 。通常,会把矩阵乘以一个列向量 ,也就是只有一列的矩阵。如,下面这个 3 × 1 3\times1 3×1 矩阵就是列向量:

python 复制代码
s = np.matrix([[0],
               [1],
               [0]])

也可以把 s 更简洁地写成 np.matrix([[0,1,0]]).T。要用方阵 A A A 乘以列向量 s s s,需要计算 A A A 的每一行与 s s s 的点积 。也就是说,如果 t = A s   m o d   Q t=As\bmod Q t=AsmodQ,那么对每一行 i i i 都有:

t i = ( A i , 0 s 0 , 0 + A i , 1 s 1 , 0 + A i , 2 s 2 , 0 )   m o d   Q . t_i=(A_{i,0}s_{0,0}+A_{i,1}s_{1,0}+A_{i,2}s_{2,0})\bmod Q. ti=(Ai,0s0,0+Ai,1s1,0+Ai,2s2,0)modQ.

输出始终是一个列向量:

python 复制代码
print(A*s % Q)
# [[2]
#  [5]
#  [8]]

这个列向量的行数等于左侧矩阵的行数。具体来说,如果把列向量 s s s 转置为 1 × 3 1\times3 1×3 矩阵,再乘以一个 3 × 1 3\times1 3×1 矩阵 r r r,最后会得到一个 1 × 1 1\times1 1×1 矩阵:

python 复制代码
r = np.matrix([[1,2,3]]).T
print(s.T*r % Q)
# [[2]]

最后一种矩阵运算是矩阵加法 。如果 A A A 和 B B B 都是 N × M N\times M N×M 矩阵,那么 C = ( A + B )   m o d   Q C=(A+B)\bmod Q C=(A+B)modQ 也是一个 N × M N\times M N×M 矩阵,并且 C i , j = ( A i , j + B i , j )   m o d   Q C_{i,j}=(A_{i,j}+B_{i,j})\bmod Q Ci,j=(Ai,j+Bi,j)modQ。当然,只有维度相同的矩阵才能相加。

3.1 热身方案

数学知识已经够了------下面开始交换密钥。从前面的 DH 示意图出发,把其中的计算替换为矩阵运算。需要注意,这个协议并不安全,但它会成为下一节中安全密钥交换机制的基础:

  • Alice 和 Bob 约定一个公开的 N × N N\times N N×N 矩阵 A A A。它对应 DH 示意图中双方约定的数 g g g。
  • Alice 选择一个随机的 N N N 维向量 s s s,向 Bob 发送 t = A s   m o d   Q t=As\bmod Q t=AsmodQ。
  • Bob 选择一个随机的 N N N 维向量 r r r,向 Alice 发送 u = r T A   m o d   Q u=r^TA\bmod Q u=rTAmodQ。也可以把它计算为 ( A T r ) T   m o d   Q (A^Tr)^T\bmod Q (ATr)TmodQ。

向量 t t t 和 u u u 对应 DH 的密钥份额。交换密钥份额后,Alice 与 Bob 就可以计算共享秘密。Alice 计算 u s   m o d   Q us\bmod Q usmodQ,Bob 计算 r T t   m o d   Q r^Tt\bmod Q rTtmodQ。二者相同,是因为:

u s = ( r T A ) s = r T ( A s ) = r T t . us=(r^TA)s=r^T(As)=r^Tt. us=(rTA)s=rT(As)=rTt.

事实上,这种密钥交换基本就是 ML-KEM 中所做的事情。不过,在此不会直接使用它,而是把它作为公钥加密方案的一部分。公钥加密包含三个算法:

  • key_gen():密钥生成算法,输出公钥加密密钥 pk 以及对应的秘密解密密钥(私钥) sk
  • encrypt():加密算法,接收公钥和明文,输出密文。
  • decrypt():解密算法,接收相应的私钥和密文,输出原始明文。也就是说,对任何明文 ptxt 都有 decrypt(sk, encrypt(pk, ptxt)) == ptxt

如果攻击者只知道密文和用于加密它的公钥,却无法在不知道相应私钥的情况下分辨出有关原始明文的任何信息,则称该方案是安全的。有了这个加密方案后,最后一步会把它转换为密钥封装机制(这正是"ML-KEM"中的"KEM",key-encapsulation mechanism)。KEM 与加密非常相似,只不过它的明文始终是一把随机生成的密钥。

对应的加密方案如下:

  • key_gen():生成密钥对时,选择随机方阵 A A A 和随机列向量 s s s。将公钥设为 ( A , t = A s   m o d   Q ) (A,t=As\bmod Q) (A,t=AsmodQ),私钥设为 s s s。注意, t t t 就是前述密钥交换协议中 Alice 的密钥份额。
  • encrypt():假设明文 ptxtrange(Q) 中的整数。为加密 ptxt,Bob 生成自己的密钥份额 u u u,再派生共享秘密并将其加到 ptxt 上。密文包含两个分量:

u = r T A   m o d   Q , v = ( r T t + m )   m o d   Q . \begin{aligned} u &= r^TA\bmod Q,\\ v &= (r^Tt+m)\bmod Q. \end{aligned} uv=rTAmodQ,=(rTt+m)modQ.

这里, m m m 是一个包含明文的 1 × 1 1\times1 1×1 矩阵,即 m = np.matrix([[ptxt]]); r r r 是随机列向量。

  • decrypt():解密时,Alice 计算共享秘密,并将其从 v v v 中减去:

m = ( v − u s )   m o d   Q . m=(v-us)\bmod Q. m=(v−us)modQ.

有些读者可能已经注意到,这看起来很像 ElGamal 加密。这并非巧合。优秀的密码学家自己造密码;伟大的密码学家从优秀的密码学家那里偷来密码。

下面把这些内容组合成代码。最后还需要一种生成随机矩阵和列向量的方法,下文称其为 gen_mat()。可以先尝试自己实现。本文方案有两个参数:模数 Q Q Q,以及矩阵和列向量的维度 N N N。 N N N 的选择会影响安全性,但现在可以随意选择一个值。

python 复制代码
def key_gen():
    # gen_mat() 返回一个 N×N 矩阵,元素从 range(0, Q) 中随机选择。
    A = gen_mat(N, N, 0, Q)
    # 与上面相同,但这里的矩阵是 N×1。
    s = gen_mat(N, 1, 0, Q)
    t = A*s % Q
    return ((A, t), s)

def encrypt(pk, ptxt):
    (A, t) = pk
    m = np.matrix([[ptxt]])
    r = gen_mat(N, 1, 0, Q)
    u = r.T*A % Q
    v = (r.T*t + m) % Q
    return (u, v)

def decrypt(sk, ctxt):
    s = sk
    (u, v) = ctxt
    m = (v - u*s) % Q
    return m[0,0]

# 测试
assert decrypt(sk, encrypt(pk, 1)) == 1

3.2 让热身方案安全起来(或者说,"什么是格?")

此时,或许正在疑惑:格到底是什么?在给出定义之前,先理解热身方案为什么不安全、以及修复它需要做什么,会更有帮助。

熟悉线性代数的读者可能已经看出了问题:要让这个方案安全,攻击者就必须无法恢复相应的私钥 s s s;然而,给定公开的 ( A , t ) (A,t) (A,t),可以立即用高斯消元法解出 s s s。

具体而言,如果 A A A 可逆,就可以把相应私钥写成:

A − 1 t = A − 1 ( A s ) = ( A − 1 A ) s = s , A^{-1}t=A^{-1}(As)=(A^{-1}A)s=s, A−1t=A−1(As)=(A−1A)s=s,

其中 A − 1 A^{-1} A−1 是 A A A 的逆矩阵。(矩阵乘以其逆矩阵会得到单位矩阵 I I I;单位矩阵不会改变列向量,即 I s = s Is=s Is=s。)可以用高斯消元法计算这个逆矩阵。直观来看,只是在求解一组线性方程,其中 s s s 的各个元素是未知变量。(即使 A A A 不可逆,仍然可以求解。)

为了让这个加密方案安全,需要把它弄得更......"混乱"一点。

3.2.1 制造一些混乱

首先,必须让人难以从公钥恢复相应私钥。可以这样尝试:再生成一个随机向量 e e e,把它加到 A s As As 中。密钥生成算法变为:

python 复制代码
def key_gen():
    A = gen_mat(N, N, 0, Q)
    s = gen_mat(N, 1, 0, Q)
    e = gen_mat(N, 1, 0, Q)
    t = (A*s + e) % Q
    return ((A, t), s)

公钥的列向量分量 t t t 现在包含一个加法项 e e e,把它称为误差。和私钥一样,误差只是一个随机向量。

注意,之前的攻击已不再奏效,因为:

A − 1 t = A − 1 ( A s + e ) = A − 1 ( A s ) + A − 1 e = s + A − 1 e . A^{-1}t=A^{-1}(As+e)=A^{-1}(As)+A^{-1}e=s+A^{-1}e. A−1t=A−1(As+e)=A−1(As)+A−1e=s+A−1e.

因此,要计算 s s s,还必须知道 e e e。

很好,但这个补丁又制造了另一个问题。花一点时间,把新的密钥生成算法放进实现并运行测试。会发生什么?

会发现 decrypt() 现在输出的是垃圾。用一点代数就能看出原因:

v − u s = ( r T t + m ) − ( r T A ) s = r T ( A s + e ) + m − ( r T A ) s = r T ( A s ) + r T e + m − r T ( A s ) = r T e + m . \begin{aligned} v-us &= (r^Tt+m)-(r^TA)s\\ &= r^T(As+e)+m-(r^TA)s\\ &= r^T(As)+r^Te+m-r^T(As)\\ &= r^Te+m. \end{aligned} v−us=(rTt+m)−(rTA)s=rT(As+e)+m−(rTA)s=rT(As)+rTe+m−rT(As)=rTe+m.

r r r 和 e e e 的元素都是随机采样的,所以 r T e r^Te rTe 也是均匀随机的。这就像用one-time pad加密了 m m m,然后把 one-time pad 扔掉了!

3.2.2 处理解密错误

该怎么办?首先,如果 r T e r^Te rTe 很小,解密结果就会接近明文,这会有所帮助。设想可以生成 r r r 和 e e e,使某个较小的 ε \varepsilon ε 满足 r T e ∈ − ε , ε r^Te\in-\\varepsilon,\\varepsilon rTe∈−ε,ε。那么 decrypt 会输出区间 p t x t − ε , p t x t + ε \\mathrm{ptxt}-\\varepsilon,\\mathrm{ptxt}+\\varepsilon ptxt−ε,ptxt+ε 中的数,与实际明文相当接近。

然而,只做到"接近"还不够。想象一下,你最喜欢的网站因为解密错误,有三分之一的时间无法加载------没人有时间忍受这种事。

ML-KEM 通过巧妙编码明文来降低解密错误的概率。假设只想加密一个比特,即 ptxt01。考虑 range(Q) 中的数字,把数轴大致均分成四段:

这里:

  • 把零附近的区域(模 Q Q Q 意义下从 − Q / 4 -Q/4 −Q/4 到 Q / 4 Q/4 Q/4)标记为 ptxt=0
  • 把远离零的区域标记为 ptxt=1

编码比特时,将其设置为对应区间中点的整数,即 m = np.matrix([[ptxt * Q//2]])。(注意两个斜杠 //,它在 Python 中表示整数除法。)解码时,根据 m[0,0] 落入哪个区间来选择相应的 ptxt。这样,只要解密误差较小,就极有可能落入正确区间。

现在只需保证解密误差 r T e r^Te rTe 足够小。为此,从中采样短向量 r r r 和 e e e。所谓"短",是指这些向量的元素来自一个远小于 range(Q) 的区间。具体来说,选择一个较小的正整数 β \beta β,从 range(-beta,beta+1) 中采样元素。

怎样选择 β \beta β?它必须足够小,使解密以压倒性概率成功;但又不能小到让 r r r 和 e e e 容易被猜中,从而导致方案被攻破。可以花一两分钟做些实验。可以调整的参数包括:

  • 模数 Q Q Q
  • 列向量维度 N N N
  • shortness 短度参数 β \beta β

在哪些参数范围内,解密错误概率很低,而秘密向量又难以猜测?从运行时间和通信成本(公钥加密文的大小)看,哪些参数范围能让方案最高效?本节末尾会给出具体答案,不过在此之前,鼓励先自己尝试一下。

3.2.3 高斯卷土重来

至此,已经得到一个可运行的加密方案,并且至少缓解了一种密钥恢复攻击。已经走了很远,但至少还有一个问题。

再看看密文公式 c t x t = ( u , v ) \mathrm{ctxt}=(u,v) ctxt=(u,v)。如果设法恢复随机向量 r r r,会发生什么?那将是灾难性的,因为 v = = r T t + m v==r^Tt+m v==rTt+m,而 t t t(公钥的一部分)和 v v v(密文的一部分)都已经公开。

与最初方案中从公钥计算私钥类似,可以用高斯消元法,根据密文分量 u u u 恢复加密随机值 r r r。原因同样是, r r r 是一组线性方程的解。

可以像之前一样,通过加入噪声缓解这种明文恢复攻击。具体来说,根据 gen_mat(N,1,-beta,beta+1) 生成一个短向量,并把它加入 u u u。出于下一节将说明的原因,还需要以相同方式向 v v v 添加噪声。

加入噪声再次提高了解密错误概率,不过这次误差大小还取决于私钥 s s s。回想一下,解密时要把 u u u 乘以 s s s(以计算共享秘密),而误差向量是一个加法项。因此,也需要让 s s s 成为短向量。

下面把所学内容组合成更新后的加密方案。它现在有三个参数 Q Q Q、 N N N 和 β \beta β,可以加密单个比特:

python 复制代码
def key_gen():
    A = gen_mat(N, N, 0, Q)
    s = gen_mat(N, 1, -beta, beta+1)
    e1 = gen_mat(N, 1, -beta, beta+1)
    t = (A*s + e1) % Q
    return ((A, t), s)

def encrypt(pk, ptxt):
    (A, t) = pk
    m = np.matrix([[ptxt*(Q//2) % Q]])
    r = gen_mat(N, 1, -beta, beta+1)
    e2 = gen_mat(N, 1, -beta, beta+1)
    e3 = gen_mat(1, 1, -beta, beta+1)
    u = (r.T*A + e2) % Q
    v = (r.T*t + e3 + m) % Q
    return (u, v)

def decrypt(sk, ctxt):
    s = sk
    (u, v) = ctxt
    m = (v - u*s) % Q
    if m[0,0] in range(Q//4, 3*Q//4):
        return 1
    return 0

# 测试
assert decrypt(sk, encrypt(pk, 0)) == 0
assert decrypt(sk, encrypt(pk, 1)) == 1

继续之前,请尝试找出一组参数,使方案既能正常工作,又让秘密向量和误差向量看起来难以猜测。

3.3 learning-with-errors(带误差学习)

目前,已经拥有一个可运行的加密方案,并缓解了两种攻击:

  • 一种密钥恢复攻击和一种明文恢复攻击。

除非所选参数弱到攻击者可以轻易猜出私钥 s s s 或密文随机值 r r r,否则似乎没有其他明显的破坏方法。再次强调,这些向量必须足够短,才能防止解密错误;但又不能短到容易猜测。(误差项同样如此。)

不过,仍然可能存在需要更复杂技巧才能实施的其他攻击。如,或许可以通过某种数学分析恢复密文随机值的一部分,或者至少对它作出很好的猜测。这引出了一个更根本的问题:一般来说,如何确认此类密码系统确实安全?

第一步,密码学家通常会设法缩小攻击面。现代密码系统的设计目标,是把攻击方案的问题归约为求解另一个更容易分析的问题。

本文的公钥加密方案很好地展示了这一思想。回想上一节的密钥恢复攻击和明文恢复攻击,它们有什么共同点?

两种情况下,攻击者都知道某个公开向量,并借此恢复秘密向量:

  • 在密钥恢复攻击中,攻击者知道满足 A s = t As=t As=t 的 t t t。
  • 在明文恢复攻击中,攻击者知道满足 r T A = u r^TA=u rTA=u 的 u u u(等价地, A T r = u T A^Tr=u^T ATr=uT)。

两种攻击采用的修复方法也相同:通过加入误差项来构造公开向量,使人难以反推出秘密。不过,最理想的情况是公开向量完全不泄露有关秘密的任何信息。带误差学习(Learning With Errors,LWE) 问题正是对这一理想目标的形式化。

LWE 问题要求攻击者区分两个分布。具体来说,设想抛一枚硬币:如果正面朝上,就从第一个分布采样并把样本交给攻击者;如果反面朝上,就从第二个分布采样并把样本交给攻击者。这两个分布如下:

  • ( A , t = A s + e ) (A,t=As+e) (A,t=As+e),其中 A A A 是通过 gen_mat(N,N,0,Q) 生成的随机矩阵, s s s 和 e e e 是通过 gen_mat(N,1,-beta,beta+1) 生成的短向量。
  • ( A , t ) (A,t) (A,t),其中 A A A 是通过 gen_mat(N,N,0,Q) 生成的随机矩阵, t t t 是通过 gen_mat(N,1,0,Q) 生成的随机向量。

第一个分布对应加密方案中的真实操作;在第二个分布中, t t t 只是一个随机向量,根本不再对应任何秘密向量。如果没有攻击者能够以显著高于二分之一的概率猜对硬币结果,就称 LWE 问题是"困难的"。

如果对所选择的参数而言 LWE 问题足够困难,那么这个加密方案就具备被动安全性 ,即密文不会泄露有关明文的任何信息。原因是,公钥和密文看起来都是 LWE 实例;如果能分别把它们替换为随机分布的实例,那么密文就与明文完全无关,因此完全不会泄露明文信息。需要注意,为使这一论证成立,还必须在密文分量 v v v 中加入误差项 e 3 e_3 e3。

3.4 选择参数

已经确定:

  • 如果使用参数 N N N、 Q Q Q 和 β \beta β 求解 LWE 很困难,那么破坏公钥加密方案也同样困难。

现在剩下的工作,是调整这些参数,使求解 LWE 超出所有能够想象的攻击者的能力范围------并且还要留出余量,以防将来发现新算法。格正是在这里登场的。

3.4.1 格

是高维空间中由无限多个点组成的网格。二维格可能是这样的:

这些点始终遵循清晰的规律,看起来很像花园里的格栅:

(来源:https://picryl.com/media/texture-wood-vintage-backgrounds-textures-8395bb)

在密码学中,关心一类特殊的格:

  • 由矩阵 P P P 定义、并由 P P P"识别"其中的点。
    • 也就是说, P P P 所识别的格由所有满足 P v = 0 Pv=0 Pv=0 的向量 v v v 构成,其中 0 0 0 表示全零向量。在 NumPy 中,全零向量写作 np.zeros((N,1), dtype=int)

熟悉线性代数的读者想到的格定义可能有所不同:一般来说,格是对某组基进行线性组合所得到的全部点。上面的格也可以用这种方式表示:也就是说,给定识别格的矩阵 P P P,可以计算生成该格的基向量。不过,本文并不太关心这种表示。

LWE 问题归根结底,是区分一组"接近"格的点与一组"远离"格的点。分别根据 LWE 实例和随机的 ( A , t ) (A,t) (A,t) 构造这些点。下图左侧是一个 LWE 样本,右侧是一个随机分布样本:

图中显示,LWE 实例中的点比随机实例中的点更接近格。平均而言确实如此。不过,虽然在二维空间中很容易区分 LWE 实例与随机实例,维度升高后,这件事会越来越困难。

下面看看这些点是怎样构造的。首先,取一个 LWE 实例 ( A , t = ( A s + e )   m o d   Q ) (A,t=(As+e)\bmod Q) (A,t=(As+e)modQ),再考虑由矩阵 P P P 所识别的格; P P P 由 A A A 与单位矩阵 I I I 拼接而成。当 N = 3 N=3 N=3 时,可能如下所示:

python 复制代码
A = gen_mat(N, N, 0, Q)
P = np.concatenate((A, np.identity(N, dtype=int)), axis=1)
print(P)
# [[1570  634  161    1    0    0]
#  [1522 1215  861    0    1    0]
#  [ 344 2651 1889    0    0    1]]

注意,把 P P P 乘以由 s s s 和 e e e 拼接而成的向量,就能计算出 t t t(这里 β = 2 \beta=2 β=2):

python 复制代码
s = gen_mat(N, 1, -beta, beta+1)
e = gen_mat(N, 1, -beta, beta+1)
t = (A*s + e) % Q
z = np.concatenate((s, e))
print(z)
# [[-2]
#  [ 0]
#  [-2]
#  [ 0]
#  [-1]
#  [ 2]]
assert np.array_equal(t, P*z % Q)

用 z z z 表示这个向量,并考虑所有满足 P v = t Pv=t Pv=t 的点 v v v。根据定义,可认为这组点"接近"格,因为 z z z 是一个短向量。(请记住,"短"是指其元素以 0 0 0 为中心,并受 β \beta β 限制。)

再考虑随机的 ( A , t ) (A,t) (A,t),以及所有满足 P v = t Pv=t Pv=t 的点 v v v。这里不作证明,但可以确定:这组点很可能"远离"格,也就是说,不存在满足 P z = t Pz=t Pz=t 的短向量 z z z。

直观而言, z z z 越长,求解 LWE 就越困难。实际上,增大 β \beta β 会提高 z z z 的平均长度,进而增加这些点到格的平均距离,使其看起来更像随机实例:

另一方面,如果让 z z z 过长,又会产生另一个问题。

3.4.2 通过寻找短向量破解格密码

给定随机矩阵 A A A,短整数解(Short Integer Solution,SIS) 问题要求找出短向量 z 1 z_1 z1 和 z 2 z_2 z2(即其元素受 β \beta β 限制),使:

( A z 1 + z 2 )   m o d   Q = 0. (Az_1+z_2)\bmod Q=0. (Az1+z2)modQ=0.

注意,这等价于在 P P P 所识别的格中寻找短向量 z z z:

python 复制代码
z = np.concatenate((z1, z2))
assert np.array_equal((A*z1 + z2) % Q, P*z % Q)

如果有一个能够求解 SIS 的(量子)计算机程序,也可以用它求解 LWE:如果 ( A , t ) (A,t) (A,t) 是 LWE 实例,那么 z 1 T t z_1^Tt z1Tt 会很小;而如果 ( A , t ) (A,t) (A,t) 是随机的, z 1 T t z_1^Tt z1Tt 就会均匀随机。(用一点代数就可以验证。)因此,要使加密方案安全,就必须难以在这些参数所定义的格中找到短向量。

直观而言,在格中寻找长向量比寻找短向量容易。这意味着, β \beta β 越接近 Q Q Q,SIS 问题就越容易求解。另一方面, β \beta β 越接近 0 0 0,区分 LWE 实例与随机实例也越容易!

这表明,基于 LWE 的加密存在一个"恰到好处"的参数区间:秘密向量和噪声向量过短时,LWE 很容易;但它们过长时,SIS 又很容易。最佳选择位于二者之间。

4. 具体参数选择

至此已具备足够的数学知识,开始做具体的参数选择。

为了调整加密方案,希望选择这样的参数:即使攻击者拥有所能想象到的全部资源------再多留一些余量,以防发现新算法------目前最高效的 LWE 求解算法(无论量子还是经典)仍然遥不可及。但人们怎么知道要防范哪些攻击?

幸运的是,格密码和格密码分析专家社区维护着一个名为 lattice-estimator 的工具,用来估算与密码学相关的格问题上,当前最佳(量子)算法的复杂度。使用该工具评估 ML-KEM 会得到以下结果(运行时需要 Sage):

text 复制代码
sage: from estimator import *
sage: res = LWE.estimate.rough(schemes.Kyber768)
usvp        :: rop: ≈2^182.2, red: ≈2^182.2, δ: 1.002902, β: 624, d: 1427, tag: usvp
dual_hybrid :: rop: ≈2^174.3, red: ≈2^174.3, guess: ≈2^162.5, β: 597, p: 4, ζ: 10, t: 60, β': 597, N: ≈2^122.7, m: 768

在此最关心的是 rop,它估算攻击所需的计算量。对该工具做一些实验后,最终可以为本方案找到一组参数,使 usvpdual_hybrid 攻击具有相近复杂度。然而,lattice-estimator 还发现一种称为 arora-gb 的攻击,它适用于本方案却不适用于 ML-KEM,而且复杂度低得多( N = 600 N=600 N=600、 Q = 3329 Q=3329 Q=3329、 β = 4 \beta=4 β=4):

text 复制代码
sage: res = LWE.estimate.rough(LWE.Parameters(n=600, q=3329, Xs=ND.Uniform(-4,4), Xe=ND.Uniform(-4,4)))
usvp        :: rop: ≈2^180.2, red: ≈2^180.2, δ: 1.002926, β: 617, d: 1246, tag: usvp
dual_hybrid :: rop: ≈2^226.2, red: ≈2^225.4, guess: ≈2^224.9, β: 599, p: 3, ζ: 10, t: 0, β': 599, N: ≈2^174.8, m: 600
arora-gb    :: rop: ≈2^129.4, dreg: 9, mem: ≈2^129.4, t: 4, m: ≈2^64.7

因此必须进一步增大方案参数,才能进入与 ML-KEM 安全性相当的范围。

最后提醒一句:设计格密码时,判断方案是否安全,远不只是估算通用攻击针对 LWE 参数的成本。在缺少现实对手模型下的数学安全性证明时,无法排除其他破坏方案的方法。善良的旅人,请谨慎前行,并带上一位伙伴共同上路。

5. 提高方案效率

现在已经理解如何使用 LWE 加密,下面快速看看怎样提高方案效率。

当前方案的主要问题是,每次只能加密一个比特。这是因为必须把 range(Q) 分成两部分,一部分编码 1,另一部分编码 0。也可以将区间划分成更多部分来提高比特率,但这样会增加解密错误概率。

另一个问题是,方案运行时间高度依赖安全参数。加密需要进行 O ( N 2 ) O(N^2) O(N2) 次乘法(乘法是安全模算术实现中成本最高的部分);为了保证方案安全,还必须让 N N N 相当大。

ML-KEM 通过用多项式环上的算术取代模整数算术,同时解决这两个问题。这意味着矩阵元素不再是整数,而是多项式。需要定义多项式的加、减、乘,但完成这些定义后,加密方案的其他部分完全不变。

事实上,很可能在小学就学过多项式算术。唯一可能不熟悉的是多项式模约减。要把两个多项式 f ( X ) f(X) f(X) 和 g ( X ) g(X) g(X) 相乘,先按通常方式计算 f ( X ) ⋅ g ( X ) f(X)\cdot g(X) f(X)⋅g(X);然后用某个特殊多项式除以乘积------ML-KEM 使用 X 256 + 1 X^{256}+1 X256+1------并取余数。本文不打算解释这一算法;关键结论是,结果为一个含 256 256 256 个系数的多项式,每个系数都是 range(Q) 中的整数。

使用多项式环进行算术的主要优势,是可以在密文中打包更多比特。 密文公式仍然完全相同,即 u = r T A + e 2 u=r^TA+e_2 u=rTA+e2、 v = r T t + e 3 + m v=r^Tt+e_3+m v=rTt+e3+m;但这次明文 m m m 编码的是一个多项式。多项式的每个系数编码一个比特;仍像之前一样处理解密错误,把 range(Q) 分成两部分,一部分编码 1,另一部分编码 0。这样,每份密文可以可靠地加密 256 位(32 字节)。

使用多项式的另一个优势,是在不影响安全性的情况下显著减小矩阵维度。具体而言,使用最广泛的 ML-KEM 变体 ML-KEM-768 采用 3 × 3 3\times3 3×3 矩阵 A A A,总共只有 9 个多项式。(注意 256 ⋅ 3 = 768 256\cdot3=768 256⋅3=768,这正是"ML-KEM-768"名称的由来。)不过,选择模多项式时必须谨慎: X 256 + 1 X^{256}+1 X256+1 的特殊之处在于,它不呈现任何已知可用于攻击的代数结构。

选择 Q = 3329 Q=3329 Q=3329 作为系数模数、选择 X 256 + 1 X^{256}+1 X256+1 作为多项式模数,还有一个额外好处:它们使多项式乘法能够使用 NTT 算法完成,大幅减少所需的乘法和加法次数。事实上,这项优化正是 ML-KEM 在 CPU 时间上有时比椭圆曲线密钥交换更快的主要原因之一。

本文不会深入介绍 NTT 的工作原理,只说明一点:如果你实现过 RSA,这个算法会看起来很熟悉。两者都使用中国剩余定理,把一次乘法拆分为多次使用较小模数、成本更低的乘法。

6. 从公钥加密到 ML-KEM

构建 ML-KEM 的最后一步,是使方案能够抵御选择密文攻击(CCA,chosen ciphertext attack)。目前它只能抵御选择明文攻击(CPA):这基本意味着,不论明文服从何种分布,密文都不会泄露有关明文的任何信息。CCA 安全性更强,因为它允许攻击者获得自行选择的密文所对应的解密结果。(当然,不允许解密目标密文本身。)ML-KEM 使用的具体变换最终会得到具备 CCA 安全性的 KEM(密钥封装机制,Key-Encapsulation Mechanism)。

选择密文攻击听起来可能有些抽象,但它实际上形式化了许多 KEM(以及公钥加密)应用中的现实威胁模型。如,假设在某协议中,服务器需要通过证明自己能解密客户端生成的密文来向客户端认证身份。在这种协议里,服务器扮演某种"解密预言机",它对客户端的响应取决于该私钥。如果方案不具备 CCA 安全性,攻击者就可能滥用这个预言机,逐渐泄露该私钥信息,并最终冒充服务器。

ML-KEM 还加入了多项优化,使其尽可能快速、紧凑。如,不必生成随机矩阵 A A A,而可以使用一种基于哈希、称为 XOF(可扩展输出函数,eXtendable Output Function)的原语,根据一个随机的 32 字节字符串(称为"种子")派生矩阵。ML-KEM 使用的 XOF 是 SHAKE128。这会显著缩小公钥。

另一项有趣的优化是,通过舍去每个系数的最低有效位来压缩密文中的多项式系数(即 range(Q) 中的整数),从而缩小密文的总体大小。

综合来看,对部署最广泛的参数 ML-KEM-768 而言,公钥为 1184 字节,密文为 1088 字节。除了减小封装密钥或公开矩阵 A A A 的大小外,没有明显的缩减办法。前者会减少 ML-KEM 可适用的场景,后者则会降低安全裕量。

需要注意,还有一些其他格方案体积更小,但它们基于不同的困难性假设,目前仍在接受分析。

7. 身份认证

上一节介绍了 ML-KEM,它已经被用于让加密具备 PQ 安全性。然而,加密只是问题的一部分:建立安全连接还需要对服务器进行身份认证------某些应用中还需要认证客户端。

身份认证通常由数字签名方案提供:使用私钥对消息签名,再使用公钥验证签名。目前使用的签名方案不具备 PQ 安全性;量子计算机可以算出服务器公钥所对应的私钥,再用该私钥冒充服务器。

虽然这个威胁不像加密所面临的威胁那样紧迫,但缓解起来会更加复杂。多年来,为了满足 Web PKI 不断变化的需求,Cloudflare团队在 TLS 握手中增添了许多签名。目前已经有这些签名的 PQ 替代方案,本节会研究其中一种;但到目前为止,这些签名及其公钥都太大(即占用太多字节),无法轻松替代现有方案。除非 NIST 正在进行的标准化工作取得某种突破,否则必须重新设计 TLS 和 Web PKI,以减少签名数量。

下面深入了解最有可能率先部署的 PQ 签名方案:ML-DSA,又名 Dilithium。ML-DSA 的设计遵循与 ML-KEM 相似的模板:先构建某种中间原语,再把它转换成最终所需的原语;这里的最终目标是签名方案。

ML-DSA 比 ML-KEM 复杂得多,因此在此会进一步简化,只求传达主要思想。

7.1 ML-DSA 热身

如果说 ML-KEM 基本上是把 ElGamal 加密中的椭圆曲线替换为格,那么 ML-DSA 基本上就是把 Schnorr 身份识别协议中的椭圆曲线替换为格。 Schnorr 协议让证明者 能够向验证者 证明自己知道与公钥对应的私钥,同时不泄露该私钥本身。协议包含三轮消息,使用四个算法执行:

  1. initialize():证明者初始化协议,并向验证者发送一个承诺
  2. challenge():验证者收到承诺后,向证明者发送一个挑战
  3. finish():证明者收到挑战后,向验证者发送证明
  4. verify():最后,验证者使用证明判断证明者是否知道该私钥。

把这个协议变成非交互协议后,就得到 ML-DSA 的高层结构。具体来说,证明者自行把承诺与待签消息一起做哈希,从而派生挑战。签名由承诺和证明组成;验证签名时,验证者根据承诺与消息重新计算挑战,再像往常一样运行 verify()

下面直接开始用格构建 Schnorr 身份识别协议。如果你以前从未见过这个协议,一开始它可能像某种黑魔法。接下来会逐步讲解它为什么以及如何工作。

和 ML-KEM 一样,公钥是一个 LWE 实例 ( A , t = A s 1 + s 2 ) (A,t=As_1+s_2) (A,t=As1+s2)。但这次私钥是一对短向量 ( s 1 , s 2 ) (s_1,s_2) (s1,s2),也就是说,它包含误差项。除此之外,密钥生成方式完全相同:

python 复制代码
def key_gen():
    A = gen_mat(N, N, 0, Q)
    s1 = gen_mat(N, 1, -beta, beta+1)
    s2 = gen_mat(N, 1, -beta, beta+1)
    t = (A*s1 + s2) % Q
    return ((A, t), (s1, s2))

为了初始化协议,证明者再生成一个 LWE 实例 ( A , w = A y 1 + y 2 ) (A,w=Ay_1+y_2) (A,w=Ay1+y2)。稍后就会明白原因。证明者把 w w w 的哈希作为承诺发送:

python 复制代码
def initialize(A):
    y1 = gen_mat(N, 1, -beta, beta+1)
    y2 = gen_mat(N, 1, -beta, beta+1)
    w = (A*y1 + y2) % Q
    return (H(w), (y1, y2))

这里的 H H H 是某种密码学哈希函数,如 SHA-3。证明者保存秘密向量 ( y 1 , y 2 ) (y_1,y_2) (y1,y2),供下一轮使用。

现在轮到验证者发出挑战。挑战只是一个整数,但必须谨慎选择。暂时先随机选取:

python 复制代码
def challenge():
    return random.randrange(0, Q)

请记住,把该协议转换为数字签名后,挑战会根据承诺 H ( w ) H(w) H(w) 和消息派生。这个哈希函数的值域必须与 challenge() 的输出集合相同。

接下来是有趣的部分。证明是一对满足以下等式的向量 ( z 1 , z 2 ) (z_1,z_2) (z1,z2):

A z 1 + z 2 = c t + w . Az_1+z_2=ct+w. Az1+z2=ct+w.

如果知道该私钥,就很容易生成这个证明:

z 1 = c s 1 + y 1 , z 2 = c s 2 + y 2 . \begin{aligned} z_1 &= cs_1+y_1,\\ z_2 &= cs_2+y_2. \end{aligned} z1z2=cs1+y1,=cs2+y2.

于是:

A z 1 + z 2 = A ( c s 1 + y 1 ) + ( c s 2 + y 2 ) = c ( A s 1 + s 2 ) + ( A y 1 + y 2 ) = c t + w . \begin{aligned} Az_1+z_2 &=A(cs_1+y_1)+(cs_2+y_2)\\ &=c(As_1+s_2)+(Ay_1+y_2)\\ &=ct+w. \end{aligned} Az1+z2=A(cs1+y1)+(cs2+y2)=c(As1+s2)+(Ay1+y2)=ct+w.

同时需满足目标:让不知道 ( s 1 , s 2 ) (s_1,s_2) (s1,s2) 的人即使观察过协议的多次执行,也难以构造 ( z 1 , z 2 ) (z_1,z_2) (z1,z2)。

为完整起见,下面给出 finish()verify() 算法:

python 复制代码
def finish(s1, s2, y1, y2, c):
    z1 = (c*s1 + y1) % Q
    z2 = (c*s2 + y2) % Q
    return (z1, z2)

def verify(A, t, hw, c, z1, z2):
    return H((A*z1 + z2 - c*t) % Q) == hw

# 测试
((A, t), (s1, s2)) = key_gen()
(hw, (y1, y2)) = initialize(A)         # hw:证明者 -> 验证者
c = challenge()                        # c:验证者 -> 证明者
(z1, z2) = finish(s1, s2, y1, y2, c)  # (z1, z2):证明者 -> 验证者
assert verify(A, t, hw, c, z1, z2)     # 验证者

注意,验证者并不直接检查 A z 1 + z 2 = c t + w Az_1+z_2=ct+w Az1+z2=ct+w。必须重排等式,才能把承诺设为 H ( w ) H(w) H(w) 而不是 w w w。下一节会解释为什么需要做哈希。

7.2 让ML-DSA方案安全起来

这个协议是否安全,归根结底取决于:不知道私钥时,是否仍有可能冒充证明者。下面戴上攻击者的帽子,四处试探一下。

也许可以直接从公钥计算该私钥,或者窃听诚实证明者执行协议的过程来得到该私钥。如果 LWE 足够困难,显然不可能从公钥 t t t 中提取该私钥。同样,承诺 H ( w ) H(w) H(w) 不会泄露任何有助于从证明 ( z 1 , z 2 ) (z_1,z_2) (z1,z2) 提取私钥的信息。

仔细观察证明。向量 ( y 1 , y 2 ) (y_1,y_2) (y1,y2) 会"掩蔽"私钥向量,就像 ML-KEM 中共享秘密掩蔽明文一样。不过有一个重要例外:还会用挑战 c c c 对该私钥向量进行缩放。

缩放这些向量会有什么影响?仔细观察几份证明,就会开始看到某种规律。先看看 z 1 z_1 z1( N = 3 N=3 N=3、 Q = 3329 Q=3329 Q=3329、 β = 4 \beta=4 β=4):

python 复制代码
((A, t), (s1, s2)) = key_gen()
print('s1={}'.format(s1.T % Q))
for _ in range(10):
    (w, (y1, y2)) = initialize(A)
    c = challenge()
    (z1, z2) = finish(s1, s2, y1, y2, c)
    print('c={}, z1={}'.format(c, z1.T))
# s1=[[   1    0 3326]]
# c=1123, z1=[[1121 3327 3287]]
# c=1064, z1=[[1060    4  137]]
# c=1885, z1=[[1884 3327  999]]
# c=269, z1=[[ 270 3325 2524]]
# c=1506, z1=[[1510 3325 2141]]
# c=3147, z1=[[3149    4  547]]
# c=703, z1=[[ 700    4 1219]]
# c=1518, z1=[[1518 3327 2104]]
# c=1726, z1=[[1726    0 1478]]
# c=2591, z1=[[2589    4 2217]]

确实,只要收集足够多的证明样本,就能对 s 1 s_1 s1 的值作出相当准确的猜测。事实上,对这些参数可以用简单的统计分析精确算出 s 1 s_1 s1。(提示: Q Q Q 是素数,所以当 c > 0 c>0 c>0 时, c ⋅ c − 1 ≡ 1 ( m o d Q ) c\cdot c^{-1}\equiv1\pmod Q c⋅c−1≡1(modQ)。)也可以对 s 2 s_2 s2 应用同样的分析,或者直接根据 t t t、 s 1 s_1 s1 和 A A A 计算 s 2 s_2 s2。

该协议的主要缺陷是:虽然秘密向量很短,但缩放之后会变得太长,无法由 ( y 1 , y 2 ) (y_1,y_2) (y1,y2) 完全掩蔽。由于 c c c 遍历整个 range(Q), c s 1 cs_1 cs1 和 c s 2 cs_2 cs2 的元素也会遍历 range(Q)。为了掩蔽这些元素, ( y 1 , y 2 ) (y_1,y_2) (y1,y2) 的元素也必须覆盖 range(Q)。然而,这又会使人能够通过求解 SIS,轻易求解 ( A , w ) (A,w) (A,w) 对应的 LWE。因此必须在 LWE 实例的向量长度与挑战导致的信息泄露之间取得平衡。

这里开始变得棘手。把 challenge() 所有可能输出构成的集合称为挑战空间。挑战空间必须相当大,大到两次输出相同挑战的概率可以忽略不计。

为什么碰撞会成为问题?在数字签名的语境下更容易看清。假设攻击者知道消息 m m m 的一份有效签名。签名包含承诺 H ( w ) H(w) H(w),因此攻击者也知道挑战为 c = H ( H ( w ) , m ) c=H(H(w),m) c=H(H(w),m)。如果它能找到另一条消息 m ∗ m^* m∗,使 c = H ( H ( w ) , m ∗ ) c=H(H(w),m^*) c=H(H(w),m∗),那么同一份签名对 m ∗ m^* m∗ 也有效!如果挑战空间------即 H H H 的可能输出集合------太小,这种攻击就很容易实施。

遗憾的是,不能仅仅通过增大模数 Q Q Q 来扩大挑战空间:挑战可能越大,泄露的私钥信息就越多。在此需要一个新思路。

7.3 两全其美:平衡 LWE 实例的向量长度与挑战导致的信息泄露

回想一下,LWE 的困难程度取决于 β \beta β 与 Q Q Q 之间的比率。这意味着 y 1 y_1 y1 和 y 2 y_2 y2 不必在绝对意义上很短,只需相对于随机向量较短。

基于这一点,考虑下面的方案:采用更大的模数,如 Q = 2 31 − 1 Q=2^{31}-1 Q=231−1,但仍然从同一个挑战空间 range(2**16) 中采样。

首先注意, z 1 z_1 z1 现在在"相对意义上"很短,因为其元素不再均匀分布于 range(Q),而是落在 range(-gamma, gamma+1) 中,其中 γ = β ( 2 16 − 1 ) \gamma=\beta(2^{16}-1) γ=β(216−1)。再修改 initialize(),让 ( y 1 , y 2 ) (y_1,y_2) (y1,y2) 的元素也从同一区间采样,然后看看结果:

python 复制代码
def initialize(A):
    y1 = gen_mat(N, 1, -gamma, gamma+1)
    y2 = gen_mat(N, 1, -gamma, gamma+1)
    w = (A*y1 + y2) % Q
    return (H(w), (y1, y2))

((A, t), (s1, s2)) = key_gen()
print('s1={}'.format(s1.T % Q))
for _ in range(10):
    (w, (y1, y2)) = initialize(A)
    c = challenge()
    (z1, z2) = finish(s1, s2, y1, y2, c)
    print('c={}, z1={}'.format(c, z1.T))
# s1=[[3 0 1]]
# c=31476, z1=[[175933 141954  93186]]
# c=27360, z1=[[    136404 2147438807     283758]]
# c=33536, z1=[[2147430945 2147377022     190671]]
# c=23283, z1=[[186516  73400   4955]]
# c=24756, z1=[[    328377 2147438906 2147388768]]
# c=12428, z1=[[2147340715     188675      90282]]
# c=24266, z1=[[    175498 2147261581 2147301553]]
# c=45331, z1=[[357595 185269 177155]]
# c=45641, z1=[[     21592 2147249191 2147446200]]
# c=57893, z1=[[297750 113335 144894]]

这显然走在正确方向上,因为 z 1 z_1 z1 与 s 1 s_1 s1 之间已经看不出明显关联( z 2 z_2 z2 与 s 2 s_2 s2 也是如此)。不过,问题还没有完全解决。

其中一个问题是挑战空间仍然太小。只有 2 16 2^{16} 216 个挑战可选时,即使只执行少量协议,也很可能发生碰撞。需要大得多的挑战空间,如约 2 256 2^{256} 2256。但这样一来,为使 β / Q \beta/Q β/Q 的比率满足安全要求, Q Q Q 就必须大得惊人。

ML-DSA 借助多项式环算术绕开了这个问题。它采用与 ML-KEM 相同的模多项式,因此挑战是一个包含 256 个系数的多项式。这些系数经过精心选择,既让挑战空间足够大,又让乘以挑战时只会以很小的幅度缩放秘密向量。需要注意,ML-DSA 使用的模数仍比 ML-KEM 略大( Q = 8380417 Q=8380417 Q=8380417),但只大约多十二位。

然而,还有一个更根本的问题:尚未完全排除签名泄露相应私钥信息的可能性。

7.4 因果关系:完全排除签名泄露相应私钥信息的可能性

假设多次运行协议,而且每次恰好都为 y 1 y_1 y1 的某个元素选择了相对较小的值。运行次数足够多后,最终就能重建 s 1 s_1 s1 的对应元素。为了排除这种可能,需要让 y 1 y_1 y1 变得更长( y 2 y_2 y2 同样如此)。但究竟要多长?

假设已知 z 1 z_1 z1 和 z 2 z_2 z2 的元素总是落在 range(-beta_loose,beta_loose+1) 中,其中 β l o o s e > β \beta_{\mathrm{loose}}>\beta βloose>β。那么可以按以下方式模拟一次诚实的协议执行:

python 复制代码
def simulate(A, t):
    z1 = gen_mat(N, 1, -beta_loose, beta_loose+1)
    z2 = gen_mat(N, 1, -beta_loose, beta_loose+1)
    c = challenge()
    w = (A*z1 + z2 - c*t) % Q
    return (H(w), c, (z1, z2))

# 测试
((A, t), (s1, s2)) = key_gen()
(hw, c, (z1, z2)) = simulate(A, t)
assert verify(A, t, hw, c, z1, z2)

这个过程能够完美模拟诚实的协议执行,也就是说,simulate() 的输出与诚实证明者真实执行协议所产生的交互记录不可区分。原因是 w w w、 c c c、 z 1 z_1 z1 和 z 2 z_2 z2 具有完全相同的数学关系(验证等式仍然成立),并且服从相同分布。

关键来了:由于这个过程不使用秘密密钥,攻击者从窃听诚实证明者那里学到的信息,不会超过它自己仅凭公钥就能计算出的信息。相当漂亮!

剩下的工作,是设法让 z 1 z_1 z1 和 z 2 z_2 z2 落入上述区间。首先修改 initialize(),把 y 1 y_1 y1 和 y 2 y_2 y2 的采样区间按 beta_loose 扩大:

python 复制代码
def initialize(A):
    y1 = gen_mat(N, 1, -gamma+beta_loose, gamma+beta_loose+1)
    y2 = gen_mat(N, 1, -gamma+beta_loose, gamma+beta_loose+1)
    w = (A*y1 + y2) % Q
    return (H(w), (y1, y2))

这样可以保证证明向量 z 1 z_1 z1 和 z 2 z_2 z2 大致均匀分布于 range(-beta_loose,beta_loose+1)。不过,它们仍有可能略微超出该区间,因此必须修改 finish(),在越界时中止 。相应地,verify() 也应拒绝越界的证明向量:

python 复制代码
def finish(s1, s2, y1, y2, c):
    z1 = (c*s1 + y1) % Q
    z2 = (c*s2 + y2) % Q
    if not in_range(z1, beta_loose) or not in_range(z2, beta_loose):
        return (None, None)
    return (z1, z2)

def verify(A, t, hw, c, z1, z2):
    if not in_range(z1, beta_loose) or not in_range(z2, beta_loose):
        return False
    return H((A*z1 + z2 - c*t) % Q) == hw

如果 finish() 返回 (None,None),证明者和验证者就应中止本轮协议并重试,直到成功:

python 复制代码
((A, t), (s1, s2)) = key_gen()
while True:
    (hw, (y1, y2)) = initialize(A)         # hw:证明者 -> 验证者
    c = challenge()                        # c:验证者 -> 证明者
    (z1, z2) = finish(s1, s2, y1, y2, c)  # (z1, z2):证明者 -> 验证者
    if z1 is not None and z2 is not None:
        break
assert verify(A, t, hw, c, z1, z2)

有趣的是,中止应当相当常见。ML-DSA 的参数经过调整,使协议平均需要运行五次才能成功。

另一个有趣之处是,安全性证明不仅要求能够模拟成功的协议执行,也要求能够模拟中止的执行。更具体地说,协议模拟器必须以与真实协议相同的概率中止,这意味着拒绝概率必须独立于相应私钥。

模拟器还必须能够为中止的交互记录生成看起来真实的承诺。这正是证明者提交 w w w 的哈希 而不是 w w w 本身的原因:在安全性证明中,可以轻松模拟随机输入的哈希值。

7.5 提高ML-DSA方案效率

ML-DSA 受益于许多与 ML-KEM 相同的优化,包括使用多项式环、使用 NTT 进行多项式乘法,以及用固定数量的比特编码多项式。不过,ML-DSA 还有一些缩小体积的额外技巧。

首先,在 ML-DSA 中,证明不再由一对短向量 z 1 z_1 z1 和 z 2 z_2 z2 组成,而只包含一个向量 z = c s 1 + y z=cs_1+y z=cs1+y,其中 y y y 已在前一步作出承诺。因此,最终只有一个证明向量 z z z,不再是之前的两个。要使其正常工作,需要采用一种特殊的承诺编码,使得无法从中计算出 y y y。ML-DSA 还使用了类似技巧来减小公钥中向量 t t t 的大小,不过具体细节更为复杂。

对于预计最先部署的参数 ML-DSA-44,公钥长 1312 字节,签名则大得惊人,达到 2420 字节。与 ML-KEM 不同,这里仍有可能再削减一些字节,但并非没有代价,而是需要让方案更加复杂。如,HAETAE 改变了所使用的分布;Falcon 更进一步,使用完全不同的方法得到更小的签名。这种方法虽然优雅,实现起来却也更复杂。

8. 总结

格密码学支撑着第一代将在互联网上广泛部署的 PQ 算法。如今,ML-KEM 已被广泛用于保护加密免受量子计算机威胁;未来几年,预计 ML-DSA 也会得到部署,以提前应对量子计算机对身份认证构成的威胁。

格还是密码学新前沿的基础:对加密数据进行计算。

假设想聚合客户端提交的一些指标,但又不想获知指标本身。使用基于 LWE 的加密,可以让每个客户端在提交前先加密指标,对密文进行聚合,最后解密得到聚合结果。

再假设一台服务器拥有一个数据库,希望让客户端访问,却不想知道客户端查询了数据库中的哪些行。基于 LWE 的加密允许以支持加密查询的方式编码数据库。

这些应用都是一种称为 FHE(全同态加密)的范式的特例。FHE 允许对加密数据执行任意计算。它是一种极其强大的原语,而目前只知道如何使用格来构建它。不过,对大多数应用来说,FHE 的实用性远不如专用协议------无论该协议是否基于格。尽管如此,多年来 FHE 一直在不断进步,对许多应用而言,它已经是一个不错的选择。

参考资料

1 2025年3月21日博客 Prepping for post-quantum: a beginner's guide to lattice cryptography

相关推荐
mutourend1 个月前
Google Cloud 后量子密码学路线图
后量子密码学
mutourend2 个月前
后量子密码学:为什么 2026 年将成为关键转折点
后量子密码学
mutourend2 个月前
Trezor Safe 7:具备量子就绪(Quantum-Ready)能力的硬件钱包
后量子密码学
mutourend3 个月前
以太坊中的量子攻击面
后量子密码学
mutourend3 个月前
CRYSTALS-Kyber(ML-KEM)简介
后量子密码学
mutourend4 个月前
以太坊中的后量子密码学
后量子密码学
mutourend4 个月前
Zcash 与量子计算机
区块链·量子计算·后量子密码学
mutourend4 个月前
量子计算与区块链:让紧迫性与真实威胁相匹配
区块链·量子计算·后量子密码学
mutourend4 个月前
Coinbase团队2026年Q1 量子计算与区块链 研报
区块链·量子计算·后量子密码学