同态加密和SEAL库的介绍(四)CKKS 方案

写在前面:

        上篇介绍了 BFV 的 Batch Encoder,其虽然充分利用了槽空间,但是每个槽只包含一个模 plain_modulus 的整数,除非 plain_modulus 非常大,否则我们可能会很快遇到数据类型溢出并在需要进行整数计算时得到意外的结果。CKKS 方案(及其编码器 CKKSEncoder)解决了数据类型溢出问题,但代价是只能得到近似结果。

一、CKKS方案介绍

下面展示 Cheon-Kim-Kim-Song (CKKS) 方案用于加密实数或复数的计算。
我们首先为 CKKS 方案创建加密参数。与 BFV 方案相比,有两个重要区别:

  1. CKKS 不使用 plain_modulus 加密参数;
  2. 在使用 CKKS 方案时,以特定方式选择 coeff_modulus 可能非常重要。示例文件 `ckks_basics.cpp` 中进一步解释这一点。在这个示例中,我们使用 CoeffModulus::Create 生成 5 个 40 位的素数。

1.1 参数设置和实例创建

EncryptionParameters parms(scheme_type::ckks);

size_t poly_modulus_degree = 8192;
parms.set_poly_modulus_degree(poly_modulus_degree);
parms.set_coeff_modulus(CoeffModulus::Create(poly_modulus_degree, { 40, 40, 40, 40, 40 }));

SEALContext context(parms);

示例这里也对SEALContext进行输出:

密钥的创建和其他类的设置跟之前相同:

KeyGenerator keygen(context);
auto secret_key = keygen.secret_key();
PublicKey public_key;
keygen.create_public_key(public_key);
RelinKeys relin_keys;
keygen.create_relin_keys(relin_keys);

Encryptor encryptor(context, public_key);
Evaluator evaluator(context);
Decryptor decryptor(context, secret_key);

1.2 CKKS的编码器

        首先要注意,CKKS用的编码器和前面的不同:[CKKSEncoder] (仅用于 CKKS 方案)。
        BatchEncoder 不能用于 CKKS 方案。CKKSEncoder 将实数或复数的向量编码为 Plaintext 对象,这些对象随后可以被加密。
        在高层次上,这看起来与 BatchEncoder 对 BFV 方案所做的非常相似,但其背后的理论完全不同

CKKSEncoder encoder(context);
size_t slot_count = encoder.slot_count();

        有一点要格外注意:在 CKKS 中,槽的数量是 poly_modulus_degree / 2,每个槽编码一个实数或复数。这应该与 BFV 方案中的 BatchEncoder 形成对比,BFV 方案中的槽数量等于 poly_modulus_degree,并排列成一个两行的矩阵。
        即能用的槽只有 poly_modulus_degree 的一半!并且如果输入的数量小于一半CKKSEncoder 在编码时会隐式地用零填充它以达到完整大小 (poly_modulus_degree / 2)。

二、编解码和运算

2.1 对输入编码和加密

先准备一个数组,并且结合上面说的(编码器会自动补全,故不需要存满):

vector<double> input{ 0.0, 1.1, 2.2, 3.3 };

        现在我们用 CKKSEncoder 对其进行编码。`input` 的浮点系数将被参数 `scale` 放大。这是必要的,因为即使在 CKKS 方案中,明文元素基本上也是具有整数系数的多项式。将 `scale` 视为决定编码精度的比特数是有启发性的;自然地,它会影响结果的精度。
        在 CKKS 中,消息是模 coeff_modulus 存储的 (在 BFV 中它是模 plain_modulus 存储),因此放大的消息不能太接近 coeff_modulus 的总大小。在这种情况下,我们的 coeff_modulus 相当大 (200 位),所以在这方面我们没什么好担心的。对于这个简单的示例,30 位的 scale 已经绰绰有余。

Plaintext plain;
double scale = pow(2.0, 30);
encoder.encode(input, scale, plain);
encryptor.encrypt(plain, encrypted);

        解密解码输出验证下正确性,可以发现其他位置的确进行了自动补全。但是仔细点会发现有 -0.00,其实就是因为近似性,可能产生了很小的误差(偏小了一点点,这里的位数看不出来)。

2.2 运算

密文上的基本操作仍然很容易进行。在这里,我们对密文进行平方运算:

evaluator.square_inplace(encrypted);
evaluator.relinearize_inplace(encrypted, relin_keys);

cout << "Scale in squared input: " << encrypted.scale() << " (" << log2(encrypted.scale()) << " bits)";

decryptor.decrypt(encrypted, plain);
encoder.decode(plain, output);

解密,解码并打印结果,(同时计算下平方后的 scale):

可以注意到:乘法结果中的 scale 增加了。实际上,它现在是原始 scale 的平方:2^{60}CKKS 方案允许在加密计算之间缩小 scale。这是一个基本且关键的特性,使 CKKS 非常强大和灵活,下面进行介绍。


三、更复杂的运算

        在本示例中,演示如何在加密的浮点输入数据 x 上评估多项式:

\pi *x^3 + 0.4*x + 1

        输入是在区间 [0, 1] 内的 4096 个等距点上,这个示例展示了 CKKS 方案的许多主要功能,但也展示了使用它的挑战。
        上文提到了,CKKS 中的乘法会导致密文中的尺度增长。任何密文的尺度都不能太接近 coeff_modulus 的总大小,否则密文将无法存储放大后的明文。CKKS 方案提供了 `rescale` 功能,可以减少尺度,并稳定尺度扩展
        Rescaling 是一种模数切换操作。像模数切换一样,它移除了 coeff_modulus 中的最后一个素数,但作为副作用,它通过移除的素数缩小了密文的尺度。(这里难理解可以看下篇关于级别的具体介绍后,再回来看)

3.1 参数设置

        通常我们希望完全控制尺度的变化,这就是为什么在 CKKS 方案中更常用精心选择的素数作为coeff_modulus。更准确地说,假设 CKKS 密文中的尺度是 S,当前 coeff_modulus 中的最后一个素数是 P。Rescaling 到下一个级别将尺度改为 S/P,并像往常一样移除 coeff_modulus 中的素数 P。素数的数量限制了可以进行的 rescaling 次数,从而限制了计算的乘法深度

        可以自由选择初始尺度。一种好的策略是将初始尺度 S 和 coeff_modulus 中的素数 P_i
设为非常接近。若密文在乘法前的尺度为 S,乘法后的尺度为 S^2,rescaling 后的尺度为 S^2/P_i。如果所有 P_i 都接近 S,则 S^2/P_i 再次接近 S。这样我们就可以在计算过程中将尺度稳定在 S 附近。
        一般来说,对于深度为 D 的电路,我们需要 rescaling D 次,即我们需要从系数模数中移除 D 个素数。一旦 coeff_modulus 中只剩下一个素数,剩余的素数必须比 S 大几个比特,以保持明文的小数点前的值。

因此,一般好的策略是为 CKKS 方案选择参数如下:

  1. 选择一个 60 位的素数作为 coeff_modulus 中的第一个素数。这将在解密时提供最高的精度;
  2. 选择另一个 60 位的素数作为 coeff_modulus 的最后一个元素,因为它将用作特殊素数,应该和其他最大的素数一样大;
  3. 选择中间的素数,使它们彼此接近。

        我们使用 CoeffModulus::Create 来生成合适大小的素数。注意,我们的 coeff_modulus 总共是 200 位,这低于我们 poly_modulus_degree 的限制:CoeffModulus::MaxBitCount(8192) 返回 218。

size_t poly_modulus_degree = 8192;
parms.set_poly_modulus_degree(poly_modulus_degree);
parms.set_coeff_modulus(CoeffModulus::Create(poly_modulus_degree, { 60, 40, 40, 60 }));

        我们选择初始尺度为 2^{40}。在最后一级时,这让我们在小数点前有 60-40=20 位的精度,并且有足够(大约 10-20 位)的精度在小数点后。由于我们的中间素数是 40 位(实际上,它们非常接近 2^{40}),我们可以实现上述的尺度稳定。

double scale = pow(2.0, 40);

SEALContext context(parms);

输出参数:

其他类的实例与之前相同:

KeyGenerator keygen(context);
auto secret_key = keygen.secret_key();
PublicKey public_key;
keygen.create_public_key(public_key);
RelinKeys relin_keys;
keygen.create_relin_keys(relin_keys);
GaloisKeys gal_keys;
keygen.create_galois_keys(gal_keys);
Encryptor encryptor(context, public_key);
Evaluator evaluator(context);
Decryptor decryptor(context, secret_key);

CKKSEncoder encoder(context);
size_t slot_count = encoder.slot_count();

这里再次强调,能用的槽空间只有poly_modulus_degree 的一半,这与BFV不同。

3.2 输入编码与加密

vector<double> input;
input.reserve(slot_count);

double curr_point = 0;
double step_size = 1.0 / (static_cast<double>(slot_count) - 1);
for (size_t i = 0; i < slot_count; i++)
{
    input.push_back(curr_point);
    curr_point += step_size;
}

Plaintext x_plain;
encoder.encode(input, scale, x_plain);
Ciphertext x1_encrypted;
encryptor.encrypt(x_plain, x1_encrypted);

输入为区间 [0, 1] 内的 4096 个等距点,打印出来能看到:

        因为要计算的多项式是:\pi *x^3 + 0.4*x + 1,这里回想一下,槽里面的运算是对应位置相乘相加。所以这里我们为  \pi、0.4 和 1 创建明文,使用 CKKSEncoder::encode 的重载函数,将给定的浮点值编码到向量的 每个槽中

Plaintext plain_coeff3, plain_coeff1, plain_coeff0;
encoder.encode(3.14159265, scale, plain_coeff3);
encoder.encode(0.4, scale, plain_coeff1);
encoder.encode(1.0, scale, plain_coeff0);

3.3 重新线性化

在进行乘法之前,有必要再补充介绍一下重线性化的概念,之前BFV那里说的比较简单:

        在同态加密中,密文的加法和乘法是支持的。加法相对简单,不会增加密文的大小。然而,密文的乘法会增加密文的大小和复杂度。例如,两个密文 c_1​ 和 c_2​ 相乘,结果密文 c_3 = c_1 \times c_2的大小和复杂度会增加。

作用:

        重线性化的主要作用是减少乘法后的密文大小,使其恢复到和原始密文相同的大小和复杂度。具体步骤如下:

  • 乘法后的密文:乘法后的密文通常会变得更大、更复杂。
  • 应用重线性化密钥:使用重线性化密钥,将复杂的密文重新线性化,降低其大小和复杂度。

何时需要

  • 密文与密文相乘:每次密文乘法后通常需要进行重线性化。(明文乘密文不用)
  • 深度运算:在多次连续乘法运算后,重线性化有助于控制密文的增长。

3.4 深度乘法运算

        为了计算 x^3,我们首先计算 x^2 并重新线性化。

Ciphertext x3_encrypted;
print_line(__LINE__);
cout << "Compute x^2 and relinearize:" << endl;
evaluator.square(x1_encrypted, x3_encrypted);
evaluator.relinearize_inplace(x3_encrypted, relin_keys);
cout << "    + Scale of x^2 before rescale: " << log2(x3_encrypted.scale()) << " bits" << endl;

 

        这里能看到,此时尺度已经增长到 2^{80}(即初始尺度的平方)。区分重新线性化,那个改变的是密文大小和复杂度,不会改变尺度,这里就需要 rescale。
        进行 rescale 除了模数切换,尺度还减少了一个等于移除的素数(40 位素数)的因子。因此,新的尺度应接近 2^{40}。然而,注意尺度并不等于 2^{40}:这是因为 40 位素数仅接近 2^{40}

evaluator.rescale_to_next_inplace(x3_encrypted);

        现在 x3_encrypted 和 x1_encrypted 处于不同的级别,这使得我们无法将它们相乘以计算 x^3。我们可以简单地将 x1_encrypted 切换到模数切换链中的下一个参数。 然而,由于我们仍然需要将 x^3 项与 \pi(plain_coeff3)相乘,我们首先计算 \pi *x 并将其与 x^2 相乘以获得 \pi *x^3。为此,我们首先计算 \pi *x 并将其从尺度 2^{80} rescale 到接近 2^{40}

Ciphertext x1_encrypted_coeff3;
evaluator.multiply_plain(x1_encrypted, plain_coeff3, x1_encrypted_coeff3);
cout << "    + Scale of PI*x before rescale: " << log2(x1_encrypted_coeff3.scale()) << " bits" << endl;
evaluator.rescale_to_next_inplace(x1_encrypted_coeff3);
cout << "    + Scale of PI*x after rescale: " << log2(x1_encrypted_coeff3.scale()) << " bits" << endl;


        由于 x3_encrypted 和 x1_encrypted_coeff3 具有相同的确切尺度并使用相同的加密参数,我们可以将它们相乘 \pi *x^3 。我们将结果写入 x3_encrypted,重新线性化并重新缩放。请注意,尽管尺度再次接近 2^{40},但由于再次被一个素数缩放,所以不完全是 2^{40}。我们已经降到了模数切换链的最后一级。

cout << "Compute, relinearize, and rescale (PI*x)*x^2." << endl;
evaluator.multiply_inplace(x3_encrypted, x1_encrypted_coeff3);
evaluator.relinearize_inplace(x3_encrypted, relin_keys);
cout << "    + Scale of PI*x^3 before rescale: " << log2(x3_encrypted.scale()) << " bits" << endl;
evaluator.rescale_to_next_inplace(x3_encrypted);
cout << "    + Scale of PI*x^3 after rescale: " << log2(x3_encrypted.scale()) << " bits" << endl;


        接下来我们计算一次项 0.4*x 。这只需要与 plain_coeff1 进行一次 multiply_plain。我们用结果覆盖 x1_encrypted。

evaluator.multiply_plain_inplace(x1_encrypted, plain_coeff1);
evaluator.rescale_to_next_inplace(x1_encrypted);

3.5 加密参数调整

        现在我们希望计算三个项的总和。然而,有一个严重的问题:由于重新缩放时的模数切换,所有三个项使用的加密参数不同
        加密的加法和减法要求输入的尺度相同,并且加密参数(parms_id)匹配。如果不匹配,Evaluator 会抛出异常。


先看加密参数的不匹配

cout << "Parameters used by all three terms are different." << endl;
cout << "+ Modulus chain index for x3_encrypted: "
     << context.get_context_data(x3_encrypted.parms_id())->chain_index() << endl;
cout << "+ Modulus chain index for x1_encrypted: "
     << context.get_context_data(x1_encrypted.parms_id())->chain_index() << endl;
cout << "+ Modulus chain index for plain_coeff0: "
     << context.get_context_data(plain_coeff0.parms_id())->chain_index() << endl;

这里的打印的是 parms_id,由于乘法后的 rescale,这个会自动切换,所以这三个不同。

        这很容易通过使用传统的模数切换(无重新缩放)来解决。CKKS 支持像 BFV 方案一样的模数切换,允许我们在不需要时移除系数模数的部分。

parms_id_type last_parms_id = x3_encrypted.parms_id();
evaluator.mod_switch_to_inplace(x1_encrypted, last_parms_id);
evaluator.mod_switch_to_inplace(plain_coeff0, last_parms_id);

再看 尺度的不相同

        具体展开解释,先将 coeff_modulus 中的素数表示为 P_0, P_1, P_2, P_3,依次排列。P_3 用作特殊模数,不参与重新缩放。计算后的密文尺度为:

  1. x^2 的乘积的尺度为 2^{80},位于级别 2;
  2. \pi *x 的乘积的尺度为 2^{80},位于级别 2;
  3. 我们将两者重新缩放到尺度 2^{80}P_2,位于级别 1;
  4. \pi *x^3 的乘积的尺度为 \left ( 2^{80} / P_{2}\right )^{2}
  5. 我们将其重新缩放到尺度\left ( 2^{80} / P_{2}\right )^{2} / P_1,位于级别 0;
  6. 0.4*x 的乘积的尺度为 2^{80}
  7. 我们将其重新缩放到尺度 2^{80} / P_2,位于级别 1;
  8. 常数项 1 的尺度为 2^{40},位于级别 2。

虽然所有三个项的尺度大约为 2^{40}但它们的确切值不同,因此它们不能相加。
这里输出一下,供大家直观的查看:

cout << "The exact scales of all three terms are different:" << endl;
ios old_fmt(nullptr);
old_fmt.copyfmt(cout);
cout << fixed << setprecision(10);
cout << "+ Exact scale in PI*x^3: " << x3_encrypted.scale() << endl;
cout << "+ Exact scale in  0.4*x: " << x1_encrypted.scale() << endl;
cout << "+ Exact scale in      1: " << plain_coeff0.scale() << endl;
cout.copyfmt(old_fmt);

        即确切值是有一定差距的。第一种解决方法是:由于 P_2P_1 非常接近 2^{40},我们可以简单地“欺骗” Microsoft SEAL,将尺度设置为相同。例如,将 \pi *x^3 的尺度改为 2^{40} 意味着我们将 \pi *x^3 的值缩放为 2^{120} / \left ( P_2 ^ 2 * P_1 \right ),这非常接近 1。这不应该导致明显的误差
        另一种方法是将 1 编码为尺度 2^{80}/P_2,与 0.4*x 进行 multiply_plain,然后重新缩放。在这种情况下,我们需要确保以适当的加密参数(parms_id)编码 1。

这里用第一种解决:

x3_encrypted.scale() = pow(2.0, 40);
x1_encrypted.scale() = pow(2.0, 40);

3.6 相加与解密

现在所有三个密文都兼容,可以相加了。

 Ciphertext encrypted_result;
 evaluator.add(x3_encrypted, x1_encrypted, encrypted_result);
 evaluator.add_plain_inplace(encrypted_result, plain_coeff0);

下面对其进行解密解码,并输出。同时打印真实结果作为验证:

vector<double> true_result;
for (size_t i = 0; i < input.size(); i++)
{
    double x = input[i];
    true_result.push_back((3.14159265 * x * x + 0.4) * x + 1);
}
print_vector(true_result, 3, 7);

decryptor.decrypt(encrypted_result, plain_result);
vector<double> result;
encoder.decode(plain_result, result);
print_vector(result, 3, 7);

通过输出可以发现,结果基本正确(近似,有一定误差)。

  • 33
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
好的,关于你的问题,我会尽力回答。不过需要说明的是,我是一个语言模型,并不能直接进行代码实现和运行。以下是基于全同态加密SEAL实现全同态算法BFV并验证其满足同态性的流程: 1. 安装SEAL并配置环境。SEAL是一个用于实现同态加密的C++,可以在官网(https://www.microsoft.com/en-us/research/project/simple-encrypted-arithmetic-library-seal/)下载。安装完成后需要进行环境配置,主要是设置编译器和SEAL的路径等。 2. 生成加密参数。BFV算法需要一些参数来进行加密和计算,如多项式次数、扰动项数量、模数等。可以使用SEAL提供的工具程序生成这些参数,也可以手动设置。 3. 加密数据。使用SEAL提供的API对需要计算的数据进行加密,得到密文。注意,加密后的数据格式与原始数据不同,需要进行转换。 4. 进行同态计算。使用SEAL提供的API对密文进行同态计算,得到结果密文。BFV算法支持加法和乘法运算,可以进行多次计算。 5. 解密结果。使用SEAL提供的API将结果密文解密得到明文结果。 6. 检验同态性。对于加法和乘法运算,需要验证其是否满足同态性质。即对于两个明文x和y,其加密结果密文为c1和c2,加法的结果为c3,有decrypt(c3) = decrypt(c1) + decrypt(c2);乘法的结果为c3,有decrypt(c3) = decrypt(c1) * decrypt(c2)。 以上就是基于全同态加密SEAL实现全同态算法BFV并验证其满足同态性的流程。需要注意的是,BFV算法虽然可以实现全同态,但是其计算效率较低,且只能处理有限的数据范围。因此在实际应用中需要根据具体情况选择适当的同态加密算法
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Mr.Ants

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值