写在前面:
本篇具体讲解如何使用 BFV 加密方案对加密的整数进行简单的计算(一个多项式评估),来源是官方提供的示例。BFV 是比较常见的方案,在很多大模型推理的时候,都是将浮点数的权重和输入变换成整数后用 BFV 方案来实现。
一、参数的设置
需要进行的第一个任务是设置一个 EncryptionParameters 类的实例。理解不同参数的行为、它们如何影响加密方案、性能以及安全级别是至关重要的。先指定方案模式:
using namespace std;
using namespace seal;
EncryptionParameters parms(scheme_type::bfv);
需要设置三个加密参数:
- poly_modulus_degree(多项式模数的度数)
- coeff_modulus([密文]系数模数)
- plain_modulus(明文模数,仅适用于BFV方案)
对加密后的密文进行操作时,只能使用特定的操作(加减法和乘法),尤其是乘法要关注其深度。因为每个密文都有一个特定的量,称为`不变噪声预算`(简称`噪声预算`),以比特为单位测量。新加密的密文中的噪声预算(初始噪声预算)由加密参数决定。同态运算以由加密参数决定的速率消耗噪声预算。
在BFV中,允许对加密数据进行的两个基本操作是加法和乘法,其中相比于乘法,加法在噪声预算消耗方面几乎可以认为是免费的。由于噪声预算在连续乘法中会累积消耗,选择适当的加密参数的最重要因素是用户想要在加密数据上评估的算术电路的乘法深度。
一旦密文的噪声预算达到零,它就会变得过于损坏而无法解密。因此,必须选择足够大的参数以支持所需的计算(过大会影响性能);否则,即使使用密钥也无法理解结果。
1.1 poly_modulus_degree
第一个参数是`多项式模数`的度数。这必须是2的正幂,代表2幂次的循环多项式的度数;
较大的poly_modulus_degree会使密文尺寸变大且所有操作变慢,但能进行更复杂的加密计算。推荐的值是1024、2048、4096、8192、16384、32768,但也可以超出这个范围。
size_t poly_modulus_degree = 4096;
parms.set_poly_modulus_degree(poly_modulus_degree);
1.2 coeff_modulus
接下来设置[密文]`系数模数`(coeff_modulus)。该参数是一个大整数,是不同素数的乘积,每个素数最多为60位。它表示为这些素数组成的向量,每个素数由Modulus类的一个实例表示。coeff_modulus 的位长度意味着其素数因子位长度的总和。
较大的 coeff_modulus 意味着较大的噪声预算,因此有更多的加密计算能力。然而,coeff_modulus 的总位长度上限由 poly_modulus_degree 决定:
poly_modulus_degree | max coeff_modulus bit-length |
1024 | 27 |
2048 | 54 |
4096 | 109 |
8192 | 218 |
16384 | 438 |
32768 | 881 |
这些数字也可以在 native/src/seal/util/hestdparms.h 中找到,编码在函数SEAL_HE_STD_PARMS_128_TC 中,也可以通过函数CoeffModulus::MaxBitCount(poly_modulus_degree) 获得。
例如,如果 poly_modulus_degree 为4096,coeff_modulus 可以由三个36位的素数组成(108位)。
SEAL提供了选择coeff_modulus的辅助函数。对于新用户,最简单的方法是直接使用CoeffModulus::BFVDefault(poly_modulus_degree),它返回一个 std::vector<Modulus>,其中包含对于给定poly_modulus_degree 通常较好的选择。
parms.set_coeff_modulus(CoeffModulus::BFVDefault(poly_modulus_degree));
1.3 plain_modulus
明文模数可以是任何正整数,尽管这里我们将其设为2的幂。事实上,在很多情况下,可能希望它是一个素数;明文模数决定了明文数据类型的大小以及乘法中噪声预算的消耗。因此,为了获得最佳性能,必须尽量保持明文数据类型尽可能小。明文模数是BFV方案特有的,使用CKKS方案时不能设置。
新加密密文中的噪声预算为:log2(coeff_modulus/plain_modulus)(比特)
同态乘法中的噪声预算消耗形式为 log2(plain_modulus) +(其他项)。
parms.set_plain_modulus(1024);
所有参数都设置好了,构建一个SEALContext对象,Microsoft SEAL将首先验证这些参数。
SEALContext context(parms);
官方例子中,打印了这个校验结果:
二、加解密
Microsoft SEAL中的加密方案是公钥加密方案。这样,多个方可以使用同一个共享公钥加密数据,但只有数据的正确接收者才能使用密钥解密。
生成密钥和公钥,我们需要一个 KeyGenerator 类的实例。构建一个 KeyGenerator 会自动生成一个密钥。然后,我们可以使用 KeyGenerator::create_public_key 为其创建任意多的公钥。注意,KeyGenerator::create_public_key 还有一个不带参数的重载,返回一个Serializable<PublicKey> 对象。
KeyGenerator keygen(context);
SecretKey secret_key = keygen.secret_key();
PublicKey public_key;
keygen.create_public_key(public_key);
// 为了能够加密,需要构建Encryptor实例。
// Encryptor只需要公钥
Encryptor encryptor(context, public_key);
对密文的计算是通过Evaluator类进行的。在实际使用中,Evaluator不会由持有密钥的一方构建。
Evaluator evaluator(context);
解密以验证一切正常,所以我们还需要构建一个 Decryptor 实例。注意,Decryptor 需要密钥。
Decryptor decryptor(context, secret_key);
三、多项式运算
示例中的运算是评估评4次多项式,输入是加密的x = 6,多项式的系数可以看作是明文输入:
在BFV方案中,明文是度小于多项式模数的多项式,系数是模明文模数的整数。对于有环理论背景的读者来说,明文空间是多项式商环 ,其中N是 poly_modulus_degree,T 是 plain_modulus。
3.1 对输入进行编码和加密
这里编码是说把输入的变量(当然向量也可以,下面在batch_encoder会介绍),编码成Plaintext 对象,然后再加密成 Ciphertext对象。这里用的构造函数将多项式作为字符串,系数表示为十六进制数:
uint64_t x = 6;
Plaintext x_plain(uint64_to_hex_string(x));
Ciphertext x_encrypted;
encryptor.encrypt(x_plain, x_encrypted);
在Microsoft SEAL中,一个有效的密文由两个或更多个多项式组成,其系数是模coeff_modulus中素数乘积的整数。密文中的多项式数量称为它的“大小”,由 Ciphertext::size()给出,新加密的密文总是大小为2。
这里可以直接打印查看其刚加密时的噪声预算:
cout << " + noise budget in freshly encrypted x: " << decryptor.invariant_noise_budget(x_encrypted) << " bits" << endl;
示例这里打印结果是:
3.2 运算
使用Microsoft SEAL时,通常有利于以最小化最长连续乘法链的方式进行计算。换句话说,最好的加密计算评估方式是最小化计算的乘法深度,因为总噪声预算消耗与乘法深度成正比。
故这里对上面的多项式计算进行因式分解,以获得一个简单的深度2的表示是有利的。
因此,我们分别计算 和 ,然后再乘以4即可。
首先,我们计算x^2并加上一个明文“1”:
Ciphertext x_sq_plus_one;
evaluator.square(x_encrypted, x_sq_plus_one);
Plaintext plain_one("1");
evaluator.add_plain_inplace(x_sq_plus_one, plain_one);
加密乘法导致输出密文的大小增加。更确切地说,如果输入密文的大小为M和N,则同态乘法后的输出密文大小为 M+N-1。我们先察密文大小的增长和噪声预算的消耗:
cout << " + size of x_sq_plus_one: " << x_sq_plus_one.size() << endl;
cout << " + noise budget in x_sq_plus_one: " << decryptor.invariant_noise_budget(x_sq_plus_one) << " bits" << endl;
示例中输出如下(这里示例还进行了解密,加以验证中间结果,注意是十六进制):
通过输出可以发现,乘法消耗了大量的噪声预算,并且增加了密文长度。但是解密可以发现,尽管大小增加了,只要噪声预算没有达到0,解密仍然可以正常工作。
接下来,我们计算 :
Ciphertext x_plus_one_sq;
evaluator.add_plain(x_encrypted, plain_one, x_plus_one_sq);
evaluator.square_inplace(x_plus_one_sq);
注意这里是用的 x_encrypted ,区分上面的中间结果 x_sq_plus_one,即噪声消耗也是针对初始的 55bit,这里对密文长度和噪声进行输出,并解密验证(注意是十六进制):
最后,我们计算 :
Ciphertext encrypted_result;
Plaintext plain_four("4");
evaluator.multiply_plain_inplace(x_sq_plus_one, plain_four);
evaluator.multiply(x_sq_plus_one, x_plus_one_sq, encrypted_result);
注:这里调用的两个乘法不一样,因为 "明文乘密文" 和 "密文乘密文" 是不一样的,前者时间更短,下面会具体讲这几个运算的函数。照上例对乘法结果的情况进行输出:
如果噪声预算达到0,这意味着解密无法得到正确的结果。这是因为密文 x_sq_plus_one 和 x_plus_one_sq 由于之前的平方运算,都包含3个多项式,并且对大密文的同态操作比对小密文的计算消耗更多的噪声预算,对较小的密文进行计算在计算上也明显更便宜。
3.3 重新线性化
Relinearization - 重新线性化 是一种将乘法后的密文大小减少回初始大小2的操作。因此,在下一次乘法前对一个或两个输入密文进行重新线性化,即使重新线性化本身具有显著的计算成本,也可以对噪声增长和性能产生巨大的正面影响。重新线性化只能将大小3的密文减少到大小2,所以通常用户会在每次乘法后重新线性化以保持密文大小为2。
但是注意这里也只是在密文乘密文后,密文大小会增大才使用的重新线性化,故一般在明文乘密文后不用重新线性化。
重新线性化需要特殊的`RelinKeys',可以将其视为一种公钥。重新线性化密钥可以通过KeyGenerator 轻松创建:
RelinKeys relin_keys;
keygen.create_relin_keys(relin_keys);
重新线性化在 BFV 和 CKKS 方案中使用类似,但在这个例子中我们继续使用BFV。我们重复之前的计算,但这次在每次乘法后进行重新线性化:
Ciphertext x_squared;
evaluator.square(x_encrypted, x_squared);
evaluator.relinearize_inplace(x_squared, relin_keys);
evaluator.add_plain(x_squared, plain_one, x_sq_plus_one);
Ciphertext x_plus_one;
evaluator.add_plain(x_encrypted, plain_one, x_plus_one);
evaluator.square(x_plus_one, x_plus_one_sq);
evaluator.relinearize_inplace(x_plus_one_sq, relin_keys);
evaluator.multiply_plain_inplace(x_sq_plus_one, plain_four);
evaluator.multiply(x_sq_plus_one, x_plus_one_sq, encrypted_result);
evaluator.relinearize_inplace(encrypted_result, relin_keys);
像之前一样,打印中间结果可以看到:
可以看出来,每次乘完的 重新线性化让多项式长度减少,避免了两个长度为三的密文相乘,改善了噪声的消耗。这样还有噪声剩余,可以进行其他进一步运算。
当然,需要注意,如果像示例中这样,运算只进行到此,即没有更深的乘法深度,即噪声预算已经够用。但是重新线性化会带来额外的时间开销,故就不一定需要了,按情况而定。
3.4 结果解密
decryptor.decrypt(encrypted_result, decrypted_result);
cout << " + decryption of 4(x^2+1)(x+1)^2 = 0x" << decrypted_result.to_string() << " ...... Correct." << endl;
打印如下:
但是需要注意一点,对于 , ,由于明文模数设置为1024,这个结果是在整数模 1024 下计算的。因此,预期输出应该是7252 % 1024 == 84,或者十六进制表示为 0x54。
因此明文模数设置的小了,就不能还原计算结果;但是太大了,计算速度就比较慢,故最好计算之前进行一定的评估,设置合理的参数。
有时我们创建的自定义加密参数会变得无效。比如这里我们简单地减少多项式模数度数,使参数不符合 HomomorphicEncryption.org 的安全标准,这些信息有助于修复无效的加密参数。
parms.set_poly_modulus_degree(2048);
context = SEALContext(parms);
print_parameters(context);
cout << "Parameter validation (failed): " << context.parameter_error_message();
四、运算函数
这里简单介绍上面所用到的几个运算的函数,方便大家更好的使用,这些都可以在 evaluator.h 里面找到。当然,大部分运算是不挑模式的,即 BFV 和 CKKS 都可以直接使用。因为内容过多,所以相似的部分就会省略。
首先来看看内存, 可以打印我们从当前内存池中分配了多少内存。默认情况下,内存池将是一个静态全局池,可以使用MemoryManager类来更改它。 大多数用户几乎没有理由修改内存分配系统。
size_t megabytes = MemoryManager::GetPool().alloc_byte_count() >> 20;
cout << "[" << setw(7) << right << megabytes << " MB] "
<< "Total allocation from the memory pool" << endl;
4.1 加法
void add_inplace(Ciphertext &encrypted1, const Ciphertext &encrypted2)
- 功能:这个函数用于将两个密文相加。它将
encrypted1
和encrypted2
相加,并将结果存储在encrypted1
中。 - 参数:
encrypted1
:第一个要相加的密文。encrypted2
:第二个要相加的密文。
- 异常:
std::invalid_argument
:如果encrypted1
或encrypted2
对加密参数无效,则抛出此异常。std::invalid_argument
:如果encrypted1
和encrypted2
处于不同的NTT形式(Number Theoretic Transform形式),则抛出此异常。std::invalid_argument
:如果encrypted1
和encrypted2
处于不同的级别或尺度,则抛出此异常。std::logic_error
:如果结果密文是透明的,则抛出此异常。
inline void add(const Ciphertext &encrypted1, const Ciphertext &encrypted2, Ciphertext &destination)
- 功能:这个函数用于将两个密文相加。它将
encrypted1
和encrypted2
相加,并将结果存储在destination
参数中。 - 参数:
encrypted1
:第一个要相加的密文。encrypted2
:第二个要相加的密文。destination
:要用加法结果覆盖的密文。
void add_many(const std::vector<Ciphertext> &encrypteds, Ciphertext &destination) const;
- 功能:这个函数用于将一个密文向量中的所有密文相加,并将结果存储在
destination
参数中。 - 参数:
encrypteds
:要相加的密文向量。destination
:要用加法结果覆盖的密文。
- 异常:
std::invalid_argument
:如果encrypteds
为空,则抛出此异常。std::invalid_argument
:如果encrypteds
中的密文对加密参数无效,则抛出此异常。std::invalid_argument
:如果encrypteds
中的密文处于不同的NTT形式,则抛出此异常。std::invalid_argument
:如果encrypteds
中的密文处于不同的级别或尺度,则抛出此异常。std::invalid_argument
:如果destination
是encrypteds
中的一个密文,则抛出此异常。std::logic_error
:如果结果密文是透明的,则抛出此异常。
同理还有加明文的:
void add_plain_inplace(
Ciphertext &encrypted, const Plaintext &plain, MemoryPoolHandle pool = MemoryManager::GetPool()) const;
inline void add_plain(
const Ciphertext &encrypted, const Plaintext &plain, Ciphertext &destination,
MemoryPoolHandle pool = MemoryManager::GetPool()) const
4.2 减法
void sub_inplace(Ciphertext &encrypted1, const Ciphertext &encrypted2) const;
- 功能:这个函数用于将两个密文相减。它计算
encrypted1
和encrypted2
的差,并将结果存储在encrypted1
中。 - 参数:
encrypted1
:要被减的密文。encrypted2
:要减去的密文。
- 异常:
std::invalid_argument
:如果encrypted1
或encrypted2
对加密参数无效,则抛出此异常。std::invalid_argument
:如果encrypted1
和encrypted2
处于不同的NTT形式,则抛出此异常。std::invalid_argument
:如果encrypted1
和encrypted2
处于不同的级别或尺度,则抛出此异常。std::logic_error
:如果结果密文是透明的,则抛出此异常。
inline void sub(const Ciphertext &encrypted1, const Ciphertext &encrypted2, Ciphertext &destination) const
- 功能:这个函数用于将两个密文相减。它计算
encrypted1
和encrypted2
的差,并将结果存储在destination
参数中。 - 参数:
encrypted1
:要被减的密文。encrypted2
:要减去的密文。destination
:要用减法结果覆盖的密文。
void sub_plain_inplace(
Ciphertext &encrypted, const Plaintext &plain, MemoryPoolHandle pool = MemoryManager::GetPool()) const;
inline void sub_plain(
const Ciphertext &encrypted, const Plaintext &plain, Ciphertext &destination,
MemoryPoolHandle pool = MemoryManager::GetPool()) const
4.3 乘法
void multiply_inplace(
Ciphertext &encrypted1, const Ciphertext &encrypted2,
MemoryPoolHandle pool = MemoryManager::GetPool()) const;
- 功能:这个函数用于将两个密文相乘。它计算
encrypted1
和encrypted2
的乘积,并将结果存储在encrypted1
中。在这个过程中,动态内存分配从给定的MemoryPoolHandle
指向的内存池中分配。 - 参数:
encrypted1
:第一个要相乘的密文。encrypted2
:第二个要相乘的密文。pool
:指向有效内存池的MemoryPoolHandle
。(一般忽略)
- 异常:
std::invalid_argument
:如果encrypted1
或encrypted2
对加密参数无效,则抛出此异常。std::invalid_argument
:如果encrypted1
或encrypted2
不处于默认的NTT形式,则抛出此异常。std::invalid_argument
:如果encrypted1
和encrypted2
处于不同的级别,则抛出此异常。std::invalid_argument
:如果输出尺度对加密参数来说太大,则抛出此异常。std::invalid_argument
:如果pool
未初始化,则抛出此异常。std::logic_error
:如果结果密文是透明的,则抛出此异常。
inline void multiply(
const Ciphertext &encrypted1, const Ciphertext &encrypted2, Ciphertext &destination,
MemoryPoolHandle pool = MemoryManager::GetPool()) const
- 功能:这个函数用于将两个密文相乘。它计算
encrypted1
和encrypted2
的乘积,并将结果存储在destination
参数中。在这个过程中,动态内存分配从给定的MemoryPoolHandle
指向的内存池中分配。 - 参数:
encrypted1
:第一个要相乘的密文。encrypted2
:第二个要相乘的密文。destination
:要用乘法结果覆盖的密文。pool
:指向有效内存池的MemoryPoolHandle
。
void multiply_plain_inplace(
Ciphertext &encrypted, const Plaintext &plain, MemoryPoolHandle pool = MemoryManager::GetPool()) const;
inline void multiply_plain(
const Ciphertext &encrypted, const Plaintext &plain, Ciphertext &destination,
MemoryPoolHandle pool = MemoryManager::GetPool()) const
4.4 平方
void square_inplace(Ciphertext &encrypted, MemoryPoolHandle pool = MemoryManager::GetPool()) const;
- 功能:这个函数用于对一个密文进行平方运算。它计算
encrypted
的平方。在这个过程中,动态内存分配从给定的MemoryPoolHandle
指向的内存池中分配。 - 参数:
encrypted
:要进行平方运算的密文。pool
:指向有效内存池的MemoryPoolHandle
。
- 异常:
std::invalid_argument
:如果encrypted
对加密参数无效,则抛出此异常。std::invalid_argument
:如果encrypted
不处于默认的NTT形式,则抛出此异常。std::invalid_argument
:如果输出尺度对加密参数来说太大,则抛出此异常。std::invalid_argument
:如果pool
未初始化,则抛出此异常。std::logic_error
:如果结果密文是透明的,则抛出此异常。
inline void square(
const Ciphertext &encrypted, Ciphertext &destination,
MemoryPoolHandle pool = MemoryManager::GetPool()) const
- 功能:这个函数用于对一个密文进行平方运算。它计算
encrypted
的平方,并将结果存储在destination
参数中。在这个过程中,动态内存分配从给定的MemoryPoolHandle
指向的内存池中分配。 - 参数:
encrypted
:要进行平方运算的密文。destination
:要用平方结果覆盖的密文。
4.5 重新线性化
inline void relinearize_inplace(
Ciphertext &encrypted, const RelinKeys &relin_keys, MemoryPoolHandle pool = MemoryManager::GetPool()) const
- 功能:这个函数用于对一个密文进行重线性化操作,减少其大小至2。如果密文的大小为K+1,则给定的重线性化密钥的大小至少需要为K-1。在这个过程中,动态内存分配从给定的
MemoryPoolHandle
指向的内存池中分配。 - 参数:
encrypted
:要进行重线性化的密文。relin_keys
:重线性化密钥。pool
:指向有效内存池的MemoryPoolHandle
。
- 异常:
std::invalid_argument
:如果encrypted
或relin_keys
对加密参数无效,则抛出此异常。std::invalid_argument
:如果encrypted
不处于默认的NTT形式,则抛出此异常。std::invalid_argument
:如果relin_keys
不对应当前上下文中的顶级参数,则抛出此异常。std::invalid_argument
:如果relin_keys
的大小太小,则抛出此异常。std::invalid_argument
:如果pool
未初始化,则抛出此异常。std::logic_error
:如果上下文不支持密钥切换,则抛出此异常。std::logic_error
:如果结果密文是透明的,则抛出此异常。
inline void relinearize(
const Ciphertext &encrypted, const RelinKeys &relin_keys, Ciphertext &destination,
MemoryPoolHandle pool = MemoryManager::GetPool()) const;
4.6 幂运算
void exponentiate_inplace(
Ciphertext &encrypted, std::uint64_t exponent, const RelinKeys &relin_keys,
MemoryPoolHandle pool = MemoryManager::GetPool()) const;
- 功能:这个函数用于对一个密文进行幂运算,将
encrypted
提升到exponent
次幂。在计算过程中,动态内存分配从给定的MemoryPoolHandle
指向的内存池中分配。幂运算以深度优化的顺序完成,并在每次乘法后自动进行重线性化,使用给定的重线性化密钥。 - 参数:
encrypted
:要进行幂运算的密文。exponent
:密文要提升的幂次。relin_keys
:重线性化密钥。pool
:指向有效内存池的MemoryPoolHandle
。
- 异常:
std::logic_error
:如果加密方案不是scheme_type::bfv
或scheme_type::bgv
,则抛出此异常。std::invalid_argument
:如果encrypted
或relin_keys
对加密参数无效,则抛出此异常。std::invalid_argument
:如果encrypted
不处于默认的NTT形式,则抛出此异常。std::invalid_argument
:如果输出尺度对加密参数来说太大,则抛出此异常。std::invalid_argument
:如果exponent
为零,则抛出此异常。std::invalid_argument
:如果relin_keys
的大小太小,则抛出此异常。std::invalid_argument
:如果pool
未初始化,则抛出此异常。std::logic_error
:如果上下文不支持密钥切换,则抛出此异常。std::logic_error
:如果结果密文是透明的,则抛出此异常。
inline void exponentiate(
const Ciphertext &encrypted, std::uint64_t exponent, const RelinKeys &relin_keys, Ciphertext &destination,
MemoryPoolHandle pool = MemoryManager::GetPool()) const
4.7 其余
还有一部分因为此处没涉及到,所以就不展开介绍了,后面解释到对应部分会再补充。包括了:
- rotate_rows_inplace
- rotate_columns_inplace
- mod_switch_to_inplace
- rescale_to_inplace
- mod_reduce_to_inplace
五、结语
在上面的例子中,我们仅使用了明文多项式的一个系数。这实际上是非常浪费的,因为明文多项式很大,而且无论如何都会被整体加密。同时,因为是取模故会产生溢出,如果直接增加 plain_modulus 参数,直到没有溢出发生,并且计算表现得像整数运算。问题在于增加 plain_modulus 会增加噪声预算的消耗,并且还会减少初始噪声预算。
在下篇将讨论其他将数据布局到明文元素(编码)的方法,这些方法可以允许更多的计算而不会发生数据类型溢出,并且可以充分利用整个明文多项式。