第一次在SEAL库里跑通CKKS的demo时,我盯着控制台输出看了很久。密文里明明是一堆乱码,中间还经历了乘法、加法、重线性化这些操作,解密之后竟然还原出一个带小数点的数,而且精度还能对得上。那一瞬间我才意识到,这跟BGV、BFV那套“整数同态”完全不是一个玩法——CKKS直接让加密域里的浮点运算变成了工程上可用的东西。
后来我把CKKS论文和开源实现对照着啃了一遍,花了几个晚上把每条公式在草稿纸上重新推了一遍,越推越觉得这个方案的设计很精巧。它没有硬碰硬地去解决“加密空间里怎么做精确小数”这个难题,而是换了个思路:把精度损失写进噪声里,让计算结果本身允许一点误差,换来了实数的动态范围和可用的性能。这篇内容就把我推导过一遍的数学基础完整梳理出来,适合两类人看:一是刚接触同态加密、想搞清楚CKKS内部原理的密码学入门者;二是想用CKKS做机器学习推理、统计分析,但又被论文符号劝退的工程同学。我会尽量把每条公式背后的“为什么”也讲清楚,而不只是列一堆记号。
1. 从“加密空间里做浮点运算”的痛点讲起
1.1 整数同态方案的“定点账本”困境
在CKKS出现之前,主流同态加密方案BGV和BFV处理的都是整数明文空间。它们的思路很朴素:把待加密的整数放到一个模数为t的环里,密文加解密、同态运算都围绕这个整数环展开。如果我想计算小数,常规做法是先乘一个大整数做定点化,比如0.1234乘以2^20变成129394,等所有运算结束再除回来。
这个方案在理论上没问题,工程上却越用越难受。定点化的本质是把“小数点的位置”写死在参数里,一旦运算深了,整数位数会迅速膨胀。我在一个金融风控的实验里试过用BFV算一个简单线性模型的推理,只做了四次乘法,为了不溢出,明文模数t从初始的2^20一路扩到2^46,参数直接爆炸,密钥体积和运算时间完全失控。关键问题在于:真实世界的浮点数据范围是动态的,有些值很大,有些值很小,用一个固定尺度去覆盖所有中间结果,要么浪费位宽,要么直接溢出。
1.2 CKKS的核心思路:把精度损失放进噪声里
CKKS论文的标题叫《Homomorphic Encryption for Arithmetic of Approximate Numbers》,翻译过来就是“近似数的同态加密”。它没有选择把明文空间做大做强,而是承认了加密过程本身带噪声,并且把“噪声”这个原本用来防护安全的负面因素,同时用作承载浮点精度误差的容器。
打个比方。BGV、BFV像在账本上记账,每一笔都要精确到分,账本纸张必须足够大,不然数字就写得超出格子。CKKS更像一个称重台秤,称出来的数值本身就带测量误差,但你可以通过“去皮重”“调校秤台”来保证结果落在可接受的误差范围内。CKKS里的缩放因子(scale)和重缩放(rescaling)就是这个“校准”动作。这个设计思想直接决定了后续所有数学推导的走向:误差是方案的一部分,不是需要清零的包袱。
明白这一点后,再去看CKKS的论文就会轻松很多。它的一切公式都在做一件事:如何在允许少量误差的前提下,让密文里的多项式能够近似表示一个复数向量,并且在加法和乘法运算后,误差仍然可控。
2. 分圆环与RLWE:CKKS的地基
2.1 为什么选 x^N+1 作为模多项式
CKKS的明文和密文都定义在分圆多项式环上。设N是2的幂,这里的环写成:
$$R = \mathbb{Z}[X] / (X^N + 1)$$
也就是说,所有多项式都在“X^N等于-1”这个规则下做约简。很多人第一次看到这里会问:为什么要挑这个模多项式,而不是随便用一个多项式?
第一个原因是效率。N取2的幂时,X^N+1的环乘法可以用数论变换NTT快速计算,复杂度是O(N log N)。同态加密的密文本来就是个巨大的多项式,乘法如果退化成朴素卷积,N=8192时一次运算就要几千万次系数乘法,根本跑不动。选这个结构就是为了让底层能用FFT式的算法加速。
第二个原因是数学上的良性质。这个分圆多项式对应的扩域中存在N个不同的本原单位根,这给了我们一个自然的方式来“嵌入”复数向量。简单说,一个次数小于N的多项式,可以通过在单位根上求值,映射成一个长度为N/2的复数向量,并且多项式的乘法在这个映射下会变成对应位置复数相乘。这个性质是后面编码算法的基础,也是CKKS能同时打包多个数据的原因。
2.2 RLWE假设:带噪声的线性关系
同态加密的安全性最终落到Ring Learning With Errors问题上。它的形式很简单:随机选一个多项式a,秘密多项式s,以及一个小噪声e,计算:
$$b = a \cdot s + e \pmod q$$
给定一组(a, b)样本,要恢复出s,这在计算上是困难的。困难的原因很直白:如果没有e,这就是一个普通的线性方程组,高斯消元几下就能解出s。但e的存在会让每个等式都带上一定偏差,方程组变成“带误差的线性系统”,解空间非常巨大且彼此接近,很难确定哪个是真正的s。
RLWE相关的一个直观理解方式是:你可以把它想象成在噪声海洋里找一条细线。x轴是a,y轴是b,数据点大致落在直线b = a·s附近,但每个点都偏离了一点。单独看一两个点,你知道它约莫在这条线附近,但无法精确锁定斜率s。收集再多点也没用,因为噪声每次都不同,而且噪声幅度远大于解密阈值时,信息就被淹没在扰动里了。
在CKKS的安全性分析中,秘密多项式s的分布通常选成系数在{-1,0,1}之间,且整体稀疏的分布,用参数h表示非零系数的个数。h越大,密钥越复杂,安全强度越高,但运算和存储开销也越大。实际选参数时要在安全和性能之间做权衡,后面第6节会展开讲。
2.3 初始噪声:加密那一刻误差就存在
很多初学者会下意识地以为,同态加密里只有不断做运算之后才会积累噪声。实际不是。从加密的第一秒开始,噪声就在密文里了。只是CKKS把这个初始噪声控制得足够小,让解密时余下空间还足以容纳后续运算带来的噪声增长。
看加密过程就能明白。假设明文是m,加密时我们会选择一个随机掩码多项式u,以及两个小噪声多项式e0、e1,输出密文为:
$$c_0 = pk_0 \cdot u + m + e_0$$
$$c_1 = pk_1 \cdot u + e_1$$
解密时计算c0 + c1·s,会发现u相关项因为公钥的构造被消掉,剩下的是m加一堆噪声组合。也就是说,密文从诞生起就是“明文+噪声”的模样。噪声不是bug,它是安全性的基石,因为正是噪声的存在,攻击者才无法从公钥和密文中剥离出有效信息。同时它也是精度的天花板,因为噪声决定了你最终能保留多少位有效数字。
3. 编码/解码:让复数向量在环里安家
3.1 规范嵌入与向量打包
CKKS明文不是直接塞一个多项式进去的,它处理的数据是复数向量。要把向量z译成环上的元素,需要用到规范嵌入(canonical embedding)。
设M = 2N,ζ是一个M次本原单位根。定义映射:
$$\sigma: R \rightarrow \mathbb{C}^{N/2},\quad a(x) \mapsto (a(\zeta^{5^0}), a(\zeta^{5^1}), \ldots, a(\zeta^{5^{N/2 - 1}}))$$
这几年看起来挺吓人,但它的含义其实很直观:把一个多项式在各本原单位根上求值,得到N/2个复数。由于多项式系数是实数(加密时我们处理的是整数系数),在共轭成对的本原单位根上的值互为共轭,所以一半取值就够了。
编码的过程就是做这个映射的逆运算:给定复数向量z,构造一个对称延展的N维向量,再做逆嵌入,得到次数小于N的多项式系数,最后乘上缩放因子并取整。解码过程反过来:多项式求值得到复数向量,除以缩放因子。
为什么不用系数表示而是建嵌入表示?因为环乘法在单位根求值域里会变成逐点乘法。你可以把多项式乘法理解成两个数组的循环卷积,但卷积运算既不直观又不高效;而在求值域里,乘法就是对应位置数值相乘,直观得多。这跟傅里叶变换域里做卷积加速是同一个思想,本质上CKKS就是“在频率域做同态计算”。
3.2 缩放因子的选择和量化误差
浮点数没法直接塞进整数系数的环里,所以编码时必须先做一步“浮点转定点”。假设明文向量z里的元素是小数,编码时先乘以缩放因子Δ,然后做四舍五入:
$$m = \mathrm{round}(\Delta \cdot z)$$
这里Δ通常取2的幂,比如2^40。选2的幂有两个原因:一是重缩放时除以Δ就是移位操作,硬件友好;二是避免缩放因子和模数q产生不必要的公因子,否则会干扰数论变换的计算和噪声分析。
这个四舍五入操作引入了量化误差,最大不超过0.5。解码时再除以Δ,量化误差被压回到2^{-41}量级。打个比方,这就像拍照时先提高像素密度再压缩存储,存储格式本身是低精度的,但通过缩放保留了足够的有效位。
量化误差会被算进最终的噪声预算里。假如你选了2^40这个尺度,那相当于告诉方案:“我每个明文值大约保留40比特的有效精度”。如果最终计算结果需要15位有效数字,那40比特勉强够用;如果你只需要8位,可以选更小的Δ,把省下来的模数空间留给更多乘法层数。
3.3 打包需要注意的“最差槽位”问题
CKKS的一个诱人特性是可以把一个长度为N/2的复数向量打包进一个密文,并行做相同操作。但很多刚上手的人会忽略一个坑:噪声预算的分配是以密文整体为单位的,不是按槽位分开的。
加密时注入的噪声是加在多项式上的,这个多项式的所有系数共享同一个上限。当向量里某个元素特别大,或者某个槽位对应的数据经过几次乘法后变成离群值,会引起更大的“明文放大效应”:误差经过乘法时会被消息幅度放大,大数值槽位上的噪声贡献会把整体噪声预算消耗得更多。最后解出来的结果里,大数可能还有六七位有效数字,小数已经烂到没法看了。所以做打包实验时,我建议看最大绝对误差,不要只看N/2个数据点的平均误差。平均值很容易骗人,一个差槽位被49个好的槽位稀释,看起来“还行”,但实际那个差槽位的结果根本不能用。
4. 密钥生成、加密与解密:公式逐行拆
4.1 私钥、公钥、演算密钥的角色分工
CKKS里有三套密钥,分工非常明确:
- 私钥sk:就是一个秘密多项式s,用于解密。
- 公钥pk:由两个多项式组成,用于加密。
- 演算密钥evk:用于重线性化,解决乘法后密文维度膨胀的问题。
公钥的构造是一个标准RLWE样本。随机选一个多项式a,采样噪声e,计算:
$$pk_0 = -a \cdot s + e \pmod q,\quad pk_1 = a$$
你可以验证,如果把公钥和私钥做内积:
$$pk_0 + pk_1 \cdot s = e \pmod q$$
结果只剩一个小噪声。这意味着公钥“知道”私钥的信息,但被噪声遮挡住了,外人无法从公钥中直接恢复出s。加密时用公钥的噪声去掩蔽消息,解密时再用私钥把这个掩蔽重新揭掉。
演算密钥evk的构造类似,但它包含的是s²的信息:
$$evk_0 = -a' \cdot s + e' + P \cdot s^2 \pmod{Pq},\quad evk_1 = a'$$
这里的P是一个额外的辅助模数,用来控制重线性化过程中的噪声。秘密s²被藏在这个密钥里,当密文出现二次项时,用evk可以把二次项“降维”回一次项,同时恢复解密形式。
4.2 加密公式里每个随机项的作用
加密一个明文多项式m时,采样一个随机掩码u,以及两个小噪声e0、e1,输出密文:
$$c_0 = pk_0 \cdot u + m + e_0$$
$$c_1 = pk_1 \cdot u + e_1$$
逐项看它的作用。u是每次加密都要重新采样的随机多项式,它的职责是让同一个明文m在不同加密中产生完全不同的密文,防止攻击者通过多次加密做统计攻击。e0和e1是噪声项,分别保护c0和c1中的信息不泄露。pk0·u这一项利用公钥的噪声来“包裹”明文m,让明文在数学上和随机数不可区分,只有知道私钥s的人才能解开这层外壳。
解密时:
$$c_0 + c_1 \cdot s = m + e_0 + e_1 \cdot s + e \cdot u \pmod q$$
公钥里藏的那个e和加密时注入的e0、e1就全部暴露出来了。它们合在一起构成初始噪声。这个初始噪声的大小决定了明文还能“扛住”多少次后续运算,所以加密时的噪声采样必须用高斯分布等带有严格方差界限的分布,不能为了省事用宽尾分布。
4.3 解密为什么允许近似
如果对照BGV/BFV的加解密公式,你会发现CKKS的加解密在形式上很像,但它多了一个“约简到区间”的动作。解密得到的多项式除以缩放因子Δ之后,才还原出原来的浮点向量。
为什么可以这样处理?因为CKKS不要求解密结果和原始明文比特级一致。它要求的是:误差上界小于最终模数q的一半。只要这个条件成立,解密得到的数值就落在可接受的精度范围内。这给了方案设计者很大的自由度:明文不需要精确占满整个模数空间,只需要在低位留够噪声的“呼吸空间”即可。
我刚开始看论文时,对这种“不精确”总觉得不踏实,总觉得加密方案不就该精确还原吗?后来想明白了一件事:实数计算本身就不是精确的。浮点数在计算机里的存储也不是精确的,每个实数运算都自带舍入误差。CKKS只是把这种“浮点误差预算”迁移到了密文域,它在概念上并没有引入额外的哲学问题——它只是把一个原本由硬件浮点单元承担的误差控制任务,改由密码协议来完成。
5. 同态运算推导:乘法、重线性化、重缩放
5.1 加法和纯文本乘法的小成本
密文加法是最便宜的操作。两个密文分别对应明文m1、m2和噪声e1、e2,按分量相加得到:
$$c_0^{add} = c_{0,1} + c_{0,2},\quad c_1^{add} = c_{1,1} + c_{1,2}$$
解密后得到:
$$m_1 + m_2 + e_1 + e_2$$
噪声是线性相加的,误差增长非常温和。乘法就完全不同了,因为两个密文的解密表达式要相乘,噪声会进入交叉项,后面单独说。
纯文本乘法值得单独提醒。明文乘以一个公开常量α,密文直接乘α就行,解密结果变成α·m + α·e。表面看起来很便宜,但如果你乘的明文已经做了自己的缩放,比如你想乘的数值是2.5,你把2.5直接编码成Δ·2.5放进环里,再做多项式乘法,那实际等价于两个缩放因子相乘,结果变成Δ²量级的东西,而不是Δ量级。很多新手在这个地方翻车,算完结果凭空多出一个Δ,解码后数值大了好几个数量级。正确做法是保证参与乘法的两个操作数缩放因子一致,或者对纯文本先做“缩放对齐”。
5.2 乘法导致s²项:重线性化怎么收尾
两个密文ct1 = (c0, c1)、ct2 = (d0, d1)相乘时,解密表达式的乘积是:
$$(c_0 + c_1 s)(d_0 + d_1 s) = c_0d_0 + (c_0d_1 + c_1d_0)s + c_1d_1 s^2$$
如果直接把这个当成新的密文,解密需要计算一个三元组的线性形式:
$$c_0' + c_1' s + c_2' s^2$$
也就是说,密文长度从2膨胀到了3,解密函数从一次多项式变成了二次多项式。如果不管,每乘一次密文长度就加1,几次之后密文变成了一个庞然大物,解密、存储、传输全部爆炸。这就必须引入重线性化(relinerization)。
重线性化的核心是用演算密钥evk把含s²的项“翻译”回含s的项。具体操作是:
$$c_0^{relin} = c_0 + \mathrm{round}(c_2 \cdot evk_0 / P)$$
$$c_1^{relin} = c_1 + \mathrm{round}(c_2 \cdot evk_1 / P)$$
代入evk的构造,可以推导出:
$$c_0^{relin} + c_1^{relin} s \approx c_0 + c_1 s + c_2 s^2 + \frac{c_2 e'}{P}$$
这里多出来的噪声项是c2·e'/P。因为额外模数P取得很大,这个噪声被压低到了和其他噪声同一个量级。所以重线性化不是免费的,它会在每次乘法之后注入定量的“重线性化噪声”。这也是为什么论文里经常把乘法噪声写成三部分:输入噪声的交叉项、明文幅度放大项,以及重线性化噪声。做参数预算时这三项都要算进去。
5.3 重缩放与模数链:精度和深度如何兑换
乘法做完后,密文对应对明文的缩放因子是Δ²。为了保持后续运算的尺度一致,需要把缩放因子降回Δ。这个过程叫重缩放(rescaling),做法是把密文整体除以一个质数p,并对系数做四舍五入:
$$c' = \mathrm{round}(c / p) \pmod{\lfloor q/p \rfloor}$$
数学上看,明文的缩放因子从Δ²变成了Δ(如果p恰好等于Δ),模数q也缩小了。这就是所谓的“模数链”机制:密钥一开始有完整的大模数q,每做一次重缩放,就消耗掉一个质因数。模数链的长度直接决定了你能做多少次逐层乘法。
把模数链想成一条消费积分链就很形象。初始模数q是总积分,缩放因子Δ是每层乘法的“入场券”,每重缩放一层就划走一张入场券,划到最后一张就再也做不了乘法了。所以“支持L层乘法”的密钥,本质上就是“初始模数足够大到能划L次”。
重缩放除了把尺度降回来,还顺带消除了密文里的部分低位噪声。因为噪声和消息都被同时除以p,低位噪声被直接截断。这一步非常关键,它让CKKS的噪声增长不像BGV那样呈现无休止的线性增长,而是每一层都“剪一次枝”,代价是模数空间的消耗。
6. 参数选择的底层逻辑与实测精度
6.1 安全强度、N和q的三角关系
CKKS的密钥参数里,环维度N直接决定安全性和计算效率的上限。在同等安全强度下,N越大,允许的最大模数q就越大,能承载的乘法深度就越高,但NTT计算量和密钥体积也翻倍增长。
以当前主流的128比特安全强度为参考,有一个常用的经验参数表:
| 环维度N | 最大模数比特数log2(q) | 适用场景 |
|---|---|---|
| 4096 | 约109 | 1层乘法以内,验证性实验 |
| 8192 | 约218 | 2到4层乘法,多数机器学习推理够用 |
| 16384 | 约438 | 4到8层乘法,复杂多项式逼近 |
| 32768 | 约881 | 大深度电路,已达工程性能极限边缘 |
这些数值来自同态加密标准社区和主流库SEAL、OpenFHE的参数集,具体上限会有几比特浮动,取决于秘密密钥的稀疏程度和安全模型的细节。选择参数时,不要自己拍脑袋定一个N就去跑,先查一下当前库默认值,再根据实际深度调整。
6.2 用“bit预算”倒推模数链
设计模数链有一个很实用的倒推方法。假设你的目标乘法深度是L,缩放因子Δ取2^p,那么模数链至少需要:
$$\log_2 q \approx L \cdot p + \text{初始噪声预留比特}$$
初始噪声预留通常需要50到80比特,这个数字和噪声分布、密文维数、重线性化噪声都有关。举个例子,L=3,p=40,预留60比特,那么总模数比特数约为180比特,落在N=8192的218比特限制内。这就是为什么很多CKKS例子用N=8192,模数链选{60, 40, 40, 40}:3层乘法,初始60比特的操作余量,之后每层40比特的缩放因子。
如果你的模数链设计出来超过安全上限,一般优先考虑缩放因子降一档,比如从30降到2^35,或者减少乘法层数。如果两者都不能动,就只能上更大的N=16384,代价是运算时间翻倍以上。没有免费午餐。
6.3 三层乘法的实测日志:噪声余额看得见
我在OpenFHE里跑过一个测试,目标是计算f(x) = x³ + x² + x,这正好需要三层串行乘法。参数选的是N=8192,scale=2^40,模数链{60, 40, 40, 40}。输入x=0.7123,作为单个数据打包进密文,其他槽位置零。测试过程如下:
直接明文计算:1.581072 CKKS解密结果:1.581008 绝对误差:6.4e-5 对应有效位数:约4到5位这个精度对这个例子的40比特尺度来说,基本符合预期。误差主要来自三个地方:编码时把0.7123转成整数近似造成的量化误差、三次乘法过程中噪声的逐步累积、以及每层重缩放时做的模约简舍入。
值得说明的是,这个结果并没有采用任何精度优化技巧。如果你把明文预编码得更仔细,或者在乘法前先做一个区间收缩的变换,误差还会更小。但裸测状态下看到这个结果,能直观感受到CKKS的噪声预算确实是一分一毫都算得清清楚楚。
6.4 精度调试的三个关键动作
最后分享三个我在实际调试中反复用到的检查方法,它们比盯着代码看半天更有效。
第一个是“同态流水线对照法”。把同样的计算在明文域里完整跑一遍,每一步都保留中间结果,然后在密文域里同步跑,每经过一次加解密就对比一次。这一步能快速揪出是哪一层引入的异常误差,是编码问题、重线性化问题,还是尺度不匹配问题。
第二个是“最大误差优先原则”。打包多个数据时,不要看均方根误差,要看所有槽位上绝对值最大的误差。因为CKKS的噪声上界是均匀作用于整个密文的,一个坏槽位意味着很多其他槽位也共享了同一个噪声环境,只是它们的明文值较小,误差看起来不明显。只看平均值会掩盖真正的危险。
第三个是“模数链留底原则”。设计模数链时,最后一层不要压到极限,要至少留20比特的冗余。我第一次设计四层乘法电路时,把模数链压得刚好够用,结果实测误差比理论估计大了一个数量级,后来发现是重线性化噪声被低估了。从那以后我每层运算都会多留一点模数余量,宁肯少做一层乘法,也不让最后一层解密结果变成噪声海洋里的一叶扁舟。
CKKS的参数调试说到底就是一场噪声预算的精算游戏。先把论文里的约简公式推明白,再在真实数据上跑一遍对比,基本就能摸透这个方案的性格。我自己推完整个流程之后最大的体会是:它没有哪一步是玄学,每一处“近似”背后都有明确的数学代价和收益,只要你愿意把公式一步步拆开,整个方案其实很清楚。