
第一次在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. 分圆环与RLWECKKS的地基2.1 为什么选 x^N1 作为模多项式CKKS的明文和密文都定义在分圆多项式环上。设N是2的幂这里的环写成$$R \mathbb{Z}[X] / (X^N 1)$$也就是说所有多项式都在“X^N等于-1”这个规则下做约简。很多人第一次看到这里会问为什么要挑这个模多项式而不是随便用一个多项式第一个原因是效率。N取2的幂时X^N1的环乘法可以用数论变换NTT快速计算复杂度是O(N log N)。同态加密的密文本来就是个巨大的多项式乘法如果退化成朴素卷积N8192时一次运算就要几千万次系数乘法根本跑不动。选这个结构就是为了让底层能用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轴是ay轴是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约1091层乘法以内验证性实验8192约2182到4层乘法多数机器学习推理够用16384约4384到8层乘法复杂多项式逼近32768约881大深度电路已达工程性能极限边缘这些数值来自同态加密标准社区和主流库SEAL、OpenFHE的参数集具体上限会有几比特浮动取决于秘密密钥的稀疏程度和安全模型的细节。选择参数时不要自己拍脑袋定一个N就去跑先查一下当前库默认值再根据实际深度调整。6.2 用“bit预算”倒推模数链设计模数链有一个很实用的倒推方法。假设你的目标乘法深度是L缩放因子Δ取2^p那么模数链至少需要$$\log_2 q \approx L \cdot p \text{初始噪声预留比特}$$初始噪声预留通常需要50到80比特这个数字和噪声分布、密文维数、重线性化噪声都有关。举个例子L3p40预留60比特那么总模数比特数约为180比特落在N8192的218比特限制内。这就是为什么很多CKKS例子用N8192模数链选{60, 40, 40, 40}3层乘法初始60比特的操作余量之后每层40比特的缩放因子。如果你的模数链设计出来超过安全上限一般优先考虑缩放因子降一档比如从30降到2^35或者减少乘法层数。如果两者都不能动就只能上更大的N16384代价是运算时间翻倍以上。没有免费午餐。6.3 三层乘法的实测日志噪声余额看得见我在OpenFHE里跑过一个测试目标是计算f(x) x³ x² x这正好需要三层串行乘法。参数选的是N8192scale2^40模数链{60, 40, 40, 40}。输入x0.7123作为单个数据打包进密文其他槽位置零。测试过程如下直接明文计算1.581072 CKKS解密结果1.581008 绝对误差6.4e-5 对应有效位数约4到5位这个精度对这个例子的40比特尺度来说基本符合预期。误差主要来自三个地方编码时把0.7123转成整数近似造成的量化误差、三次乘法过程中噪声的逐步累积、以及每层重缩放时做的模约简舍入。值得说明的是这个结果并没有采用任何精度优化技巧。如果你把明文预编码得更仔细或者在乘法前先做一个区间收缩的变换误差还会更小。但裸测状态下看到这个结果能直观感受到CKKS的噪声预算确实是一分一毫都算得清清楚楚。6.4 精度调试的三个关键动作最后分享三个我在实际调试中反复用到的检查方法它们比盯着代码看半天更有效。第一个是“同态流水线对照法”。把同样的计算在明文域里完整跑一遍每一步都保留中间结果然后在密文域里同步跑每经过一次加解密就对比一次。这一步能快速揪出是哪一层引入的异常误差是编码问题、重线性化问题还是尺度不匹配问题。第二个是“最大误差优先原则”。打包多个数据时不要看均方根误差要看所有槽位上绝对值最大的误差。因为CKKS的噪声上界是均匀作用于整个密文的一个坏槽位意味着很多其他槽位也共享了同一个噪声环境只是它们的明文值较小误差看起来不明显。只看平均值会掩盖真正的危险。第三个是“模数链留底原则”。设计模数链时最后一层不要压到极限要至少留20比特的冗余。我第一次设计四层乘法电路时把模数链压得刚好够用结果实测误差比理论估计大了一个数量级后来发现是重线性化噪声被低估了。从那以后我每层运算都会多留一点模数余量宁肯少做一层乘法也不让最后一层解密结果变成噪声海洋里的一叶扁舟。CKKS的参数调试说到底就是一场噪声预算的精算游戏。先把论文里的约简公式推明白再在真实数据上跑一遍对比基本就能摸透这个方案的性格。我自己推完整个流程之后最大的体会是它没有哪一步是玄学每一处“近似”背后都有明确的数学代价和收益只要你愿意把公式一步步拆开整个方案其实很清楚。