做同态加密的人,十有八九是从CKKS开始入坑的,原因也很简单——它支持浮点数的近似运算,跟机器学习、统计分析里的场景天然匹配。但说实话,CKKS的数学门槛不算低,光“编码/解码”和“重缩放”这两件事,就能劝退不少刚接触的人。这篇博文我想把CKKS的核心数学基础从头捋一遍,从环结构、误差分布到编解码、密钥生成、加解密、同态乘法,再到重缩放和模数链,尽量做到每个公式都解释清楚“为什么要这么算”。适合正在做隐私计算、联邦学习相关项目的同学,也适合刚入门同态加密、想搞明白库里面“黑盒操作”背后原理的开发者和研究者。
CKKS全称是Cheon-Kim-Kim-Song,2017年由首尔大学团队提出,属于层次型同态加密方案(Leveled HE)。它跟BFV、BGV最大的区别在于,后两者处理的是整数多项式上的精确运算,而CKKS允许浮点数的近似计算,换句话说就是把“误差”当作方案的一部分直接接受。这个设计一出来,直接让同态加密从“玩具级别的整数加法”往前迈了一大步,也让它真正有机会落到实际业务里。下面我就按自己的理解,把CKKS从底层到应用层一层层拆开讲。
1. 为什么要啃CKKS的数学:一个总览
很多人学CKKS只看库的API,调一调参数,跑通加解密就觉得自己会了。但真到业务场景里,十有八九会碰壁:要么编码精度不够,要么乘法深度超了,要么错误地估计了噪声预算,最后解出来的数据一团糟。要搞明白这些,就得回到数学层面去看——CKKS的每一步设计,都是数学在背后决定的。
1.1 同态加密家族里CKKS到底解决什么问题
同态加密这个概念其实很直接:允许你在密文上做运算,运算结果解密后等于明文做同样运算的结果。理想情况下,你希望云端只看到密文,却能在密文上完成全部计算。这在数据安全、隐私计算、联邦学习里都是硬需求。
在CKKS之前,主流方案是BGV和BFV,它们能处理的是整数环上的精确运算,适合做数据库查询、统计计数这类整数值业务。可机器学习和大部分科学计算几乎全是浮点数,你要是用BFV去算一个线性回归,得先做定点化缩放,每乘一次就得截断一次,运算深度稍微大一点,精度立刻崩掉。CKKS的出现就是为了解决这个问题:它本身就在密文里模拟浮点运算的舍入效应,把“误差”变成方案内在的一部分,而不像BFV那样力求精确。业界经常会说BFV是“整数同态”,CKKS是“近似同态”,这个“近似”就是它最大的卖点,也是它最难理解的地方。
1.2 这套方案适合谁,怎么学才算真正吃透
如果你只是用现成的库,SEAL、OpenFHE、HEAAN都封装得不错,那看这篇推导的意义更多在于理解参数为什么这么配、为什么某些操作那么贵。如果你是做底层开发或者想做性能优化,那这部分基础就是必修课了。
我的建议是,学CKKS不要一上来就追源码,先把五件事搞明白:一是底层的RLWE安全假设,二是多项式环里的编码解码,三是加密解密的数学形态,四是同态乘法和重线性化,五是重缩放和模数链。这五件事每一个都会在后续实操里反复出现。当你把这几环真正串起来,再回头看文档里的参数配置,就会有一种“原来如此”的通透感。
2. 环、高斯误差与RLWE假设:CKKS的地基
CKKS的所有操作都在一个特定的代数结构上完成,那就是分圆多项式对应的剩余环。理解这个环为什么存在、为什么长这样,比背几个公式重要得多。
2.1 多项式环与分圆结构
CKKS最常用的环是 R = Z[X] / (X^N + 1)
其中N是2的幂。为什么偏偏要用X^N + 1而不是别的多项式?原因是它满足两个关键性质:第一,X^N + 1是分圆多项式,它的根是2N次本原单位根,这保证了环里可以做高效的数论变换(NTT),密文乘法能加速到接近线性级别;第二,这个多项式是“完全分裂”的,在很多模数下都能分裂成一次因式,这让背后的明文槽(plaintext slot)编码成为可能。
你可以把R想象成一个N维的整数格点空间,每个环元素就是一个系数向量。明文多项式、密文多项式、密钥多项式都生活在这个环里。不同之处是,明文多项式的系数通常很小,密文多项式的系数则被取模到q,密钥多项式则是从特殊分布里采样的。
这里有一个容易忽略的点:CKKS的明文空间并不直接被定义为这个环,而是先用实数向量编码成多项式,再进入环进行运算。编码前后的映射关系,才是CKKS数学基础和BFV拉开差距的地方。
2.2 离散高斯采样与误差分布
同态加密的安全性来自于一个叫“带误差学习”(Learning With Errors,LWE)的困难问题,CKKS使用的是它的环版本,也就是RLWE。简单说,给定一个环上的随机元素a,和另一个元素b = a·s + e,其中s是密钥,e是服从窄高斯分布的误差,从(b, a)里反推s是非常困难的。这个“困难”不是单纯的计算量大,而是目前学术界公认的最坏情况格问题困难性可以归约到它身上。
实操里采样e的时候要特别小心,我用过一个库默认高斯参数太宽,导致密文噪声增长比预期快得多。一般来说,高斯分布的标准差σ需要根据安全强度选定,常见取σ在3.2到8之间,具体的还要配合安全参数评估工具来算。误差分布越大越安全,但噪声预算越小,加密后能做的乘法次数越少,所以安全性和可用性在这里是一对天生的矛盾。
2.3 RLWE问题的“困难性”如何变成安全性
RLWE的安全论证链条是这样的:如果攻击者能通过公钥破解密文,那他就能解决RLWE问题;而RLWE问题在参数正确选取时,难度等价于某个格问题的最坏情况。这段归约听起来很美好,但实际落地时,参数选取的稍微不合理,安全性就大打折扣。
我自己见过不少人为了压低噪声把q调得很小,结果安全强度直接从128比特掉到80比特以下。所以这里必须提醒一句:不要自己凭感觉乱选q和N,建议用官方安全参数表,或者用LWE估计器(lattice-estimator)去验证。你省下来的那点噪声预算,换来的可能是整个系统的安全塌方。
3. 编码与解码:把实数塞进多项式的艺术
BFV、BGV这些方案处理整数时,直接对待加密数字做进制分解就行。CKKS不行,因为它要处理的是复数向量,而底层环的加法和乘法都是多项式运算。于是问题来了:怎么把N/2个复数放到一个N次多项式里,并且保证多项式乘法对应到向量分量乘法?答案就是正则嵌入。
3.1 正则嵌入与明文槽
设N=2^k,分圆多项式X^N + 1的根是ξ, ξ^3, ξ^5, ..., ξ^(2N-1),其中ξ是2N次本原单位根。CKKS利用这些根的前N/2个(在某种排序下)构成一个同构,把环R映射到C^(N/2)上。这个映射就是正则嵌入σ:
σ(m) = (m(ξ), m(ξ^3), ..., m(ξ^(N-1)))
m是明文多项式,σ(m)就是它在这N/2个根上的求值。反过来,给定一个复数向量z,想找到对应的多项式m,需要对z做逆映射。这个逆映射本质上是解一个范德蒙德线性方程组,复杂度是O(N log N),可以用高效的逆NTT变体完成。
这里的关键是“明文槽”概念:向量z的第i个分量,会被独立地编码到多项式里,两个明文多项式做乘法,对应的明文槽就各自独立相乘,互不干扰。换句话说,一次密文乘法同时完成了N/2个复数的乘法,这就是CKKS能做SIMD风格批处理运算的数学原理。
3.2 缩放、取整与泰勒近似:误差从哪里来
仅仅把复数向量逆嵌入到多项式里还不够,因为正则嵌入的输入必须是共轭对称的实数系数产生的复数值,而实际业务里的向量可能不满足这个条件。CKKS论文给出的处理方式分两步:
第一步是把复数向量z扩展成共轭对称向量z',即对后半部分取共轭。这是因为多项式在共轭根上的取值必然共轭,所以天然满足对称性。 第二步是做缩放。原始向量里的每个分量可能是任意实数,多项式系数却是整数,直接舍入会带来很大的相对误差。CKKS在编码前先乘一个缩放因子Δ(通常取2^p),把向量放大到整数范围,再取整得到整数系数,这样精度就能围绕2^p来分配。
这里就是近似性的第一层来源:缩放取整本身引入了舍入误差,误差幅度大约在0.5以内,相对误差大约1/Δ。实际使用中,Δ的选取要结合后续乘法深度来定。比如计划做L层乘法,那么初始缩放因子至少要取Δ = 2^(p / L)级别的约束,否则到后期精度会跌破可用阈值。我在项目里经常先按目标精度倒推Δ,再往前推出模数大小,这个顺序几乎不会错。
3.3 一个N=8的编码实例
理论说多了容易绕,我拿一个极小的例子演示。假设N=8,分圆多项式是X^8 + 1,明文槽数量是N/2=4。假设要编码向量z = (1+2i, -3+4i, 5+i, 6-2i)。
第一步,把z扩展成共轭对称的8维向量,即保证σ解码时后半部分能被自动共轭匹配。 第二步,乘上缩放因子Δ=16,得到(16+32i, -48+64i, 80+16i, 96-32i)以及对应的共轭项。 第三步,对这8个值做逆范德蒙德插值,得到整数系数多项式m(x)。 第四步,实际存储的是m的整数系数,因为缩放后已经取整。
解码时,把m代入4个根上求值,得到(16+32i, -48+64i, ...),然后除以16,得到近似z。由于取整引入误差,解码值通常带有约0.03~0.05的扰动,这个扰动在密文运算里还会被后续计算放大。
很多初学者在这个地方容易犯一个错误:直接拿不满足共轭对称的向量去编码。库通常不会报错,但解密后结果会错得莫名其妙,查半天才发现是编解码映射不匹配。
4. 密钥生成、加密与解密的形式化推导
理解了环和编码之后,加解密本身其实就顺理成章了。CKKS的密钥体系由三个部分组成:私钥、公钥和重线性化密钥。后者的作用我放到乘法那一节再展开。
4.1 密钥生成
密钥生成过程如下:
- 采样一个私钥多项式 s ← R,系数通常从{ -1, 0, 1 }里抽取,所以s是一个稀疏且系数极小的多项式。
- 采样一个随机多项式 a ← R_q,并从离散高斯分布采样一个误差多项式 e ← χ。
- 计算公钥 pk = (b, a),其中 b = -a·s + e mod q。
注意这个形式:b和a都在模q的环里取值。攻击者面对的是“找s”的RLWE问题:已知(a, b),找一个短的s和小的e使得b ≈ -a·s。由于RLWE困难性,攻击者拿不到有效信息。而拥有s的接收方可以很容易算出b + a·s = e,虽然不知道e的精确值,但能确定它就是一个小误差。
除此之外还要生成重线性化密钥evk,用于乘法后的密钥切换。它的形式类似于对s²做某种“加密”,具体生成方式是:选择随机a',计算evk = (b', a'),其中b' = -a'·s + e' + p·s²。这里p是一个特殊的基,通常取模数q的一个“缩放因子”,用来在分解密文时平衡精度和开销。evk的作用在乘法那一节会看得更清楚。
4.2 加密解密的数学形态
加密一个明文多项式m的过程是:
- 采样随机多项式 v ← R,通常系数从{ -1, 0, 1 }里均匀采样。
- 采样误差多项式 e0, e1 ← χ。
- 计算密文对 (c0, c1) = (v·b + m + e0, v·a + e1)。
密文长度是2。解密的时候,用私钥s计算:
c0 + c1·s = v·b + m + e0 + v·a·s + e1·s = v·(-a·s + e) + m + e0 + v·a·s + e1·s = m + v·e + e0 + e1·s
看到了吗?v·a·s和-v·a·s正好抵消,剩下的除了m之外,只有v·e、e0、e1·s这三项噪声。只要这三项足够小,m就能被正确恢复出来。我们在工程里常说的“噪声预算”,就是指明文m的振幅预期值和噪声项振幅预期值之间的差距。这个差距是有限的,而每次同态操作都会消耗它。
4.3 为什么会带误差:在RLWE语义下的安全论证
有人可能会问:为什么解密非要差个e,而不是精确相等?因为在RLWE安全模型里,公钥里必须包含噪声,否则攻击者可以通过解线性方程组直接恢复私钥。CKKS的“近似”就是把这个噪声作为方案的一部分,刻意让解密结果带一个可控的误差。用户拿到误差范围内的数值,就能继续使用。
这里我要补充一个容易被忽略的细节:v的采样方式直接关系到安全性。很多框架默认v的系数从{ -1, 0, 1 }采样,但少数实现为了减少密文大小,会把v设成稀疏分布,这其实暗藏风险。之前有研究发现,v过稀可能导致密钥恢复攻击,所以建议用标准实现里的安全默认值,别为一点性能去动v的分布。
5. 同态运算:加法、乘法和重线性化
到了最核心的部分。CKKS能在密文上做加法和乘法,靠的是环本身的代数结构。加法好理解,乘法则牵扯到密文维数膨胀和密钥切换,这也是整个方案里最容易让人卡壳的地方。
5.1 加法为什么简单
如果两个密文分别是(c0, c1)和(d0, d1),它们的明文噪声分别是e1和e2,那么逐分量相加: c_add_0 = c0 + d0 c_add_1 = c1 + d1
解密时: c_add_0 + c_add_1·s = (m1 + noise1) + (m2 + noise2)
噪声是线性叠加的,误差增幅大约是两倍,非常温和。这也是同态加密里最廉价的运算,几乎不消耗多少噪声预算。但注意,如果连续做很多次加法,噪声也会累积到不可忽略的程度,只是相比乘法来说小得多。
5.2 乘法的张量积展开与三项密文
密文乘法就没有那么简单了。给定两个密文c = (c0, c1)和d = (d0, d1),我们想要一个密文能解出m1·m2。
先看解密过程的乘积: (c0 + c1·s) · (d0 + d1·s) = c0·d0 + (c0·d1 + c1·d0)·s + c1·d1·s²
所以理论上可以把三元组(c0·d0, c0·d1 + c1·d0, c1·d1)作为“三维密文”。任何持有私钥s的人都能解出m1·m2:
c0·d0 + (c0·d1 + c1·d0)·s + c1·d1·s² ≈ m1·m2
问题来了:这个密文从2项变成了3项。如果接下来继续做乘法,密文项数会指数级膨胀,完全无法控制。标准的解决方案是“重线性化”,也称密钥交换。
5.3 重线性化:用密钥交换把密文拉回两项
重线性化的目标很明确:把三元组(c0, c1, c2)重新变成两元组(c0', c1'),使得:
c0' + c1'·s ≈ c0 + c1·s + c2·s²
做法是利用之前生成的evk密钥。把c2做基分解,分解成若干个小数字: c2 = ∑_i c2_i · p^i 这里的p是重线性化基数,通常取2的幂,用来控制分解精度和性能。然后: c0' = c0 + ∑_i c2_i · evk0_i c1' = c1 + ∑_i c2_i · evk1_i
其中evk0_i和evk1_i来自evk的分解块。由于evk本身编码的是s²,这个交换在解密时正好把s²项“降回”s,同时只引入一个很小的额外噪声。
这一段我第一次看的时候绕了很久,后来总结了一个直观理解:重线性化其实就是“用公钥式的工具把s²的贡献折算到s上”,相当于把三维投影回二维,代价是加了一点点噪声。在工程实现里,这一步也是密文乘法最贵的一环,很多性能优化都围绕它展开,比如用NTT加速、用RNS分解减少乘法开销等。
6. 重缩放与模数链:控制精度的核心
做同态乘法时,明文本身还会伴随缩放因子的膨胀。第一次编码时我们乘了Δ,乘法运算后明文值会近似变成Δ²·m1·m2。如果继续做乘法,数值会指数级爆炸。重缩放就是为了把这个Δ²重新拉回Δ量级,同时控制噪声增长。
6.1 为什么乘法后必须做重缩放
假设初始明文编码为Δ·m1和Δ·m2,乘法后得到的明文是Δ²·m1·m2。如果此时不处理,下一次乘法就会变成Δ⁴级别的缩放,到最后系数数量级远超模数q,解密时直接溢出。重缩放的操作就是让密文整体除以Δ,并做系数取整:
c_rescale = round( (1/Δ) · c ) mod q'
这样解密后得到的大约是Δ·m1·m2,缩放因子又回到Δ。同时,除以Δ会连带把部分噪声也缩小,相当于一定程度“清理”了噪声。不过要注意,噪声的实际相对水平并不会因此降低,它只是被按比例缩放,所以归根结底,噪声预算还是每次乘法都会消耗。
在具体实现里,重缩放并不是简单除以一个数,而是利用模数链结构:预先选择一系列模数q_L > q_{L-1} > ... > q_0,每次重缩放就把密文从模q_l切换到模q_{l-1}。这么做的好处是,取整过程可以通过RNS基的切换高效完成,不需要做大整数除法。
6.2 模数链与RNS表示下的实现真相
CKKS的参数设计里,模数链的长度直接决定能做多少次乘法。如果每层乘法都伴随一次重缩放,那模数从q_L降到q_0之间有多少个“台阶”,就能支持多少次带重缩放的乘法。
具体关系是:log q_L ≈ log q_0 + L · log Δ。所以你的乘法深度L越大,初始模数就要越大,密文也就越大,运算开销也越高。工程里的优化思路通常是在保证安全强度的前提下,尽量选择小的q和合适的Δ,合理规划乘法深度。有些任务只需要两层乘法,你却配置了一个能支持十几层乘法的参数,那纯粹是浪费内存和算力。
另外必须提RNS表示。CKKS里的多项式系数动辄上千比特,不可能直接存成一个大整数数组。RNS表示把它拆成多个互素模数下的余数,每个余数可以放进64位机器字。所有加法和乘法都可以在各个余数通道上并行完成。重缩放时,只需要丢弃一个余数通道,再对剩余通道做基数变换校正,开销远小于直接做高精度除法。理解了RNS,再看库里的“重新线性化密钥数量”和“特殊模数”概念,思路会清晰很多。
6.3 噪声预算和参数选择经验
说到参数选择,我踩过的坑值得拿出来分享。第一,Δ不是越大越好。Δ决定精度,但同时也决定模数大小。同样的乘法深度下,Δ翻倍,log q就要增加约1比特,密文变大,运算变慢。第二,明文槽数量N/2越大,能批处理的数据越多,但N越大,安全强度在同等q下会下降,所以需要更大的N才能撑住同等安全强度。N、q、L、Δ这四个量是相互制约的,调任何一个都要考虑其他三个。
我一般是这样做的:先定业务需要的乘法深度L和精度位数p;然后根据L和p算出Δ和初始模数大小的约束;再用这个约束对照安全参数表,选一个满足128比特安全性的N;最后在真实数据上跑一遍端到端测试,看最终误差是否达标。这样走一圈,虽然前面要算几步,但能大幅减少“调了半天参数最后精度崩了”的情况。
7. 踩坑与调参心得
这里我把实操中遇到的典型问题整理成一个速查表,方便大家对照排查。
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 解密结果完全不对 | 编码时未满足共轭对称扩展 | 检查明文的slot维度是否为N/2,确认库的编码API是否自动做共轭扩展 |
| 单次乘法后精度损失过大 | 缩放因子Δ太小 | 增大Δ,重新计算模数链长度,确认q_0还有余量 |
| 多次乘法后噪声爆炸 | 重缩放时机不对或者模数链不够长 | 确认每层乘法后调用重缩放,重新估算L |
| 性能比预期慢很多 | N选得过大,或者重线性化密钥数量过多 | 检查安全强度是否过高,适当降低N和q到目标强度 |
| 使用RNS时结果偶发错误 | 模数链里互素模数选择不当 | 确认模数都是互素的,且特殊模数足够大以支持基数变换 |
还有一个特别容易踩的坑:不同库对缩放因子的处理不一致。比如SEAL里的CKKS默认用固定缩放因子,但有些版本在乘法后会自动重缩放,而OpenFHE里缩放因子可以在每一层动态调整。这意味着同一组参数在不同库里跑出来的误差可能完全不一样,切换库的时候一定要重新做参数标定,不能直接沿用。
最后再分享一个小技巧:调试CKKS项目时,千万不要只在整数上测试。CKKS的近似特性在遇到特殊数据时会放大误差,比如数据范围横跨好几个数量级、或者存在极大的异常值。我是习惯写一个自动化的错误评估脚本,在每次密文运算的中间节点都插入解密验证,看噪声是怎么一步步累积的,这比到最后再找原因高效得多。
CKKS的数学基础说难确实难,但真正理解了环、RLWE、编解码、重缩放这条主线之后,再看任何同态加密库的文档,都会觉得清爽很多。希望这篇推导能帮你把最模糊的几块拼图补上,少走点我当初走过的弯路。别急着一口气背下所有公式,先拿一个小参数例子,自己手算一遍加解密和乘法,体会会完全不一样。