4. 密钥生成与加密流程:公钥、私钥、评估密钥的生成,明文空间的编码与加密操作

好,我们正式进入同态加密的“发动机舱”。

上一章我们聊了数学基础——多项式环、噪声、密钥交换。这一章,我们把这些概念拧成一股绳,看看一套可用的同态加密方案到底是怎么跑起来的。说白了,就是 把数学公式变成能用的钥匙和锁。

我个人习惯把密钥生成比作“配钥匙”。不是随便配一把,而是配三把:一把公开的、一把私藏的、一把专门干活的。

4.1 密钥结构:三把钥匙的逻辑

在主流方案(比如 BGV、BFV、CKKS)里,密钥不是只有一对。你至少需要这三样东西:

  • 公钥 (pk):用来加密。谁都能拿,相当于一个上了锁的投递口。
  • 私钥 (sk):用来解密。只有你持有,相当于那把唯一的物理钥匙。
  • 评估密钥 (evk):用来运算。也是公开的,但只能用来做同态计算,不能解密。我管它叫“算盘钥匙”。

为什么要单独拎一个评估密钥出来?我在项目中遇到过这样的坑:刚开始做原型时,我把公钥直接扔给服务器做运算,结果发现 密文尺寸膨胀得离谱。后来才意识到,评估密钥是专门给服务器做“密钥交换”和“重线性化”用的,不参与加密。这就像给装修师傅一把“工具钥匙”——能开门进去干活,但打不开你的保险柜。

我的经验。 别想着省事把私钥给服务器做运算。一旦私钥泄露,整个系统就废了。评估密钥虽然叫“密钥”,但它本质上是一组加密后的数据,泄密了也不怕,顶多算力会受影响。

4.2 密钥生成流程:一步步拆解

我们拿 CKKS 方案举个例子,它用在浮点数近似计算上,很适合日志聚合的场景。流程其实挺直白的:

  1. 选参数:确定多项式阶数 N(一般是 215 到 217)、模数 q(决定了能承载的噪声天花板)。
  2. 采样:从多项式环 Rq 上随机生成一个秘密多项式 s,这就是私钥。
  3. 生成公钥:随机选一个 a 和一个小的噪声 e。计算 b = -a·s + e。公钥就是 (a, b)。
  4. 生成评估密钥:对私钥的平方 s2 进行加密(用另一个辅助模数)。这个过程稍微复杂,但你可以理解为:给“乘法运算”配了一把临时钥匙。

嗯,这里要注意:公钥 (a, b) 本质上是对私钥的一层“遮盖”。攻击者看到了 a 和 b,想反推出 s,就需要解 RLWE 问题。而这个问题的难度,我们之前说过——非常靠谱。

密钥生成与加密流程总览

下图展示了从参数输入到密文输出的完整路径:

密钥生成与加密流程 密钥生成与加密流程总览 密钥生成 输入参数 N, q, Δ 私钥 sk 公钥 pk 评估密钥 evk 编码 明文空间 m → 明文多项式 m' 加密 m' + 噪声 → 密文 ct 密文输出 (ct₀, ct₁) 💡 关键点: • 密钥生成和编码是独立步骤,可以并行。 • 公钥公开,评估密钥公开,私钥永不出域。

4.3 明文空间的编码与加密操作

你想想看,同态加密不能直接加密一个浮点数,比如 3.1415926。为什么?因为加密是在多项式环上操作的,你得先把数组或实数 映射 成多项式。这个映射过程就叫编码。

在 CKKS 方案里,我们用了一种很巧妙的方法:

  • 编码:把复数向量(比如日志中的时间戳、温度值)做一次逆傅里叶变换(iDFT),再缩放一个缩放因子 Δ,变成一个整系数多项式。
  • 加密:用公钥把这个多项式“包”起来,加上随机噪声,产生两个多项式 (ct0, ct1)。
我曾经踩过一个坑: 有一次编码时忘了做缩放,直接把小数的多项式塞进加密函数。结果解密出来全是垃圾。因为环上的运算天然是整数的,小数被当成整数处理,噪声一叠加就彻底歪了。CKKS 的编码逻辑必须配合缩放因子,这是它的命门。

代码实现上(伪代码,你要是用开源库,比如 SEAL,流程一模一样):

// 编码
Plaintext encode(vector<double> values, double scale) {
    // 1. iDFT 变换
    Polynomial p = inverse_fft(values);
    // 2. 缩放成整数
    p = p * scale;
    return p;
}

// 加密
Ciphertext encrypt(PublicKey pk, Plaintext p, double noise) {
    // 1. 采样随机多项式 u、噪声 e0、e1
    Polynomial u = sample_uniform();
    Polynomial e0 = sample_small_noise();
    Polynomial e1 = sample_small_noise();
    // 2. 计算密文 (ct0, ct1)
    Ciphertext ct;
    ct.ct0 = pk.b * u + e0 + p;    // 明文在这里被“埋”进去
    ct.ct1 = pk.a * u + e1;
    return ct;
}
为什么解密能恢复明文? 解密时用私钥算一下:
ct0 + ct1 * sk = (b*u + e0 + p) + (a*u + e1) * sk
代入 b = -a·sk + e,你会发现大多数项互相抵消,只剩下 p + (e0 + e1·sk + e·u)。只要噪声够小,就能把原始明文 p 分离出来。在同态运算过程中,噪声随着乘法逐渐变大,所以我们要在它爆炸前用评估密钥 “降噪”。

4.4 三种密钥的存储与使用建议

在实际项目中,密钥管理往往比加密逻辑更容易出问题。我列了一个表格,你可以在部署时参考:

密钥类型 存储位置 分发范围 补,数据量提醒
私钥 (sk) 本地安全硬件或机密计算环境 永远不外发 极小,通常只需几十KB
公钥 (pk) 公开数据库或配置中心 所有数据提供方 较大,约1-2MB
评估密钥 (evk) 计算服务器本地磁盘 仅限负责计算的节点 最大,可达数百MB

我建议你在生产环境里,把私钥放在 TEE(信任执行环境) 或 HSM(硬件安全模块) 里。公钥和评估密钥可以交给配置中心分发,但要注意 完整性校验,防止中间人篡改。

4.5 一个完整的实例:加密一条日志

假设我们要加密一条日志“温度传感器 A 读数为 36.5”,过程是这样的:

  1. 编码:把数组 [36.5] 缩放 240 倍,变成整数多项式。
  2. 加密:用公钥生成密文,加上噪声。输出两个大数多项式。
  3. 存储:把这两个多项式序列化存储(比如 Protobuf),然后发到日志服务器。

你看,服务器看到的只是一堆杂乱的大数,但它在需要时可以直接对这些密文做聚合查询,完全不需要解密。这就是同态加密的魔力——密文等同于数据的使用权,而不只是可见权。

我的一个小习惯。 在实际库中(比如 Microsoft SEAL、OpenFHE),加密函数的输入参数里有一项 scale,你记得保持一致。我见过同事不小心在不同批次用了不同 scale,结果聚合时解密全乱了。这个问题排查起来非常烧脑,因为错误只会在计算链的最后一步暴露。