同态加密的目标很直接:允许计算引擎在看不到明文的情况下完成加法、乘法等操作,解密后得到的结果与直接对明文计算一致。Microsoft SEAL作为微软开源的成熟同态加密库,用C++实现了BFV和CKKS两种常用方案,并且通过node-seal这个绑定包,Node.js开发者可以直接在服务端调用底层能力。

一、Microsoft SEAL支持的同态方案与适用场景
Microsoft SEAL不是传统意义上的全同态加密库,它属于分级同态加密实现,可以在噪声预算耗尽前执行有限深度的算术电路。SEAL提供两种核心方案:BFV适合整数精确计算,CKKS适合浮点数近似计算。BFV内部使用模算术,加密、解密结果与明文完全一致,适合投票计数、小额累加、布尔逻辑等场景;CKKS会引入可控的精度损失,换来了对实数运算的支持,适合机器学习推理、统计分析和信号处理等任务。
同态运算时,噪声会随着乘法快速增长。每次乘法都会把两个密文中的噪声相乘,如果不加处理,几次操作后解密可能得到错误结果。SEAL允许设置系数模数链,通过重线性化和模数切换控制噪声增长。因此在实际开发前,要先判断自己的业务数据是整数还是浮点数,以及需要多少步乘法和加法。这个判断直接影响后面的参数配置。
BFV还有一个显著优势是支持批处理。一个密文内部可以同时打包成百上千个整数槽位,对密文执行一次加法,就等价于对每一组对应槽位分别执行加法。这种SIMD特性非常适合批量处理用户行为统计、风控特征累加等任务。不过批处理要求所有槽位执行相同的运算,如果业务中存在大量分支判断,就需要在明文侧先完成条件拆分。
二、在Node.js中初始化SEAL环境
node-seal通过WebAssembly方式封装了Microsoft SEAL的C++实现,安装时不需要在服务器上准备C++编译链。初始化时先引入包并调用异步工厂函数SEAL,该调用会返回一个已经被初始化的SEAL实例,后续所有对象都通过这个实例创建。下面代码创建一个BFV加密上下文,并检查参数是否合法。
const { SEAL } = require('node-seal')
async function createSealContext() {
const seal = await SEAL()
const schemeType = seal.SchemeType.bfv
const securityLevel = seal.SecurityLevel.tc128
const degree = 4096
const coeffModulus = seal.CoeffModulus.Create(degree, Int32Array.from([36, 36, 37]))
const plainModulus = seal.PlainModulus.Batching(degree, 20)
const parms = seal.EncryptionParameters(schemeType)
parms.setPolyModulusDegree(degree)
parms.setCoeffModulus(coeffModulus)
parms.setPlainModulus(plainModulus)
const context = seal.Context(parms, true, securityLevel)
if (!context.parametersSet()) {
throw new Error('SEAL context 参数无效')
}
return { seal, context }
}
参数中polyModulusDegree决定多项式模数次数,常见取值为2048、4096、8192。这个值越大,安全强度越高,同时可用槽位也越多,但计算速度会下降。coeffModulus是系数模数链,数组中的每个数字代表一个模数的比特长度,例如[36, 36, 37]表示三段模数分别占36位、36位和37位。三段模数总比特数越大,噪声预算越充足,但密文也会变大。最后一个参数plainModulus是明文模数,它决定了BFV中整数的取值范围。
如果参数组合不合理,context.parametersSet()会返回false,这时不应该继续创建密钥或加密器,否则后续操作可能抛出难以定位的异常。生产环境可以把参数校验放在启动阶段,一旦失败立即终止服务并输出完整配置,方便快速排查版本兼容问题。
三、密钥生成、加密与解密流程
BFV方案需要一对公钥和私钥。公钥交给不可信计算节点,用来加密明文;私钥保留在可信端,用来解密结果。密钥生成器KeyGenerator可以同时创建公钥和私钥。加密前需要先通过BatchEncoder把整数数组编码为明文多项式,解密后再用同一个编码器把多项式还原成数组。
// 基于上一段 context 继续
const keyGenerator = seal.KeyGenerator(context)
const publicKey = keyGenerator.createPublicKey()
const secretKey = keyGenerator.secretKey()
const encoder = seal.BatchEncoder(context)
const encryptor = seal.Encryptor(context, publicKey)
const decryptor = seal.Decryptor(context, secretKey)
const input = Int32Array.from([1, 2, 3, 4, 5, 6, 7, 8])
const plain = encoder.encode(input)
const ciphertext = seal.Ciphertext()
encryptor.encrypt(plain, ciphertext)
console.log('密文大小:', ciphertext.save().length)
const decryptedPlain = seal.PlainText()
decryptor.decrypt(ciphertext, decryptedPlain)
const decoded = encoder.decode(decryptedPlain)
console.log('解密结果:', Array.from(decoded))
上面的Int32Array长度必须小于等于polyModulusDegree,否则编码器会抛出长度错误。BFV批处理对每个槽位独立编码,因此即使某些槽位暂时不用,也要填入占位整数,不能混入undefined或NaN。加密后的ciphertext可以序列化成二进制,方便存入文件、Redis或数据库。
私钥绝不能下发到客户端或发往不可信服务。如果计算服务在云端,建议把私钥放在独立的解密服务中,由解密服务监听消息队列,接收密文后解密并返回明文结果。公钥可以公开分发,因为从公钥推导私钥在RLWE困难性假设下不可行。
四、同态加法与乘法的实现与噪声控制
密文加法可以直接使用Evaluator完成。加法的噪声增长相对缓慢,通常可以执行很多次而不必担心噪声预算耗尽。乘法则完全不同,两个密文相乘会产生更大的密文规模和更高的噪声。常见做法是每次乘法后立即执行重线性化,把密文规模恢复到正常水平。重线性化需要一个额外的重线性化密钥,可以在创建密钥生成器时生成。
const evaluator = seal.Evaluator(context)
const relinKeys = keyGenerator.createRelinKeys()
const left = encoder.encode(Int32Array.from([2, 4, 6, 8, 10, 12, 14, 16]))
const right = encoder.encode(Int32Array.from([3, 5, 7, 9, 11, 13, 15, 17]))
const leftCipher = seal.Ciphertext()
const rightCipher = seal.Ciphertext()
encryptor.encrypt(left, leftCipher)
encryptor.encrypt(right, rightCipher)
const sumCipher = seal.Ciphertext()
evaluator.add(leftCipher, rightCipher, sumCipher)
const productCipher = seal.Ciphertext()
evaluator.multiply(leftCipher, rightCipher, productCipher)
evaluator.relinearize(productCipher, relinKeys, productCipher)
const sumPlain = seal.PlainText()
decryptor.decrypt(sumCipher, sumPlain)
console.log('加法结果:', Array.from(encoder.decode(sumPlain)))
加法示例中的输出结果应为5, 9, 13, 17, 21, 25, 29, 33。乘法结果如果需要验证,可以再对productCipher执行解密和解码。注意BFV中的乘法是在模plainModulus下完成的,如果两个整数的乘积超过明文模数,结果会发生回绕。选择明文模数时要根据业务数值上界提前计算,必要时使用更大的plainModulus或改用CKKS方案。
重线性化只减少密文规模,不会消除已经产生的噪声。如果乘法次数较多,应该增大coeffModulus的总比特长度,例如把三段模数改成[40, 40, 40, 40]这样的四段结构。增加模数链虽然能容纳更多噪声,但也会增大密文和公钥的体积,需要根据网络带宽和存储成本综合权衡。
五、CKKS浮点运算与重缩放
CKKS专门处理实数近似运算,它不使用明文模数,而是通过一个缩放因子scale把浮点数放大为整数进行编码。缩放因子越大,计算精度越高,但噪声预算消耗也越快。典型的scale值是2的30次方到2的50次方,具体取决于业务需要的有效小数位数。
CKKS的另一个关键操作是重缩放。每次乘法后密文会携带更大的缩放因子,如果不重缩放,后续运算会迅速失真。重缩放同时也会降低密文模数链中的一层,因此可用的乘法深度与初始系数模数链长度直接相关。下面是一个平方运算的示例。
const ckksParms = seal.EncryptionParameters(seal.SchemeType.ckks)
ckksParms.setPolyModulusDegree(8192)
ckksParms.setCoeffModulus(seal.CoeffModulus.Create(8192, Int32Array.from([60, 40, 40, 60])))
const ckksContext = seal.Context(ckksParms, true, seal.SecurityLevel.tc128)
const ckksKeyGenerator = seal.KeyGenerator(ckksContext)
const ckksPublicKey = ckksKeyGenerator.createPublicKey()
const ckksSecretKey = ckksKeyGenerator.secretKey()
const ckksEncoder = seal.CKKSEncoder(ckksContext)
const ckksEncryptor = seal.Encryptor(ckksContext, ckksPublicKey)
const ckksDecryptor = seal.Decryptor(ckksContext, ckksSecretKey)
const ckksEvaluator = seal.Evaluator(ckksContext)
const ckksRelinKeys = ckksKeyGenerator.createRelinKeys()
const scale = Math.pow(2, 40)
const values = Float64Array.from([0.5, 1.25, 2.75, 3.1])
const plainCkks = ckksEncoder.encode(values, scale)
const cipherCkks = seal.Ciphertext()
ckksEncryptor.encrypt(plainCkks, cipherCkks)
const squareCipher = seal.Ciphertext()
ckksEvaluator.square(cipherCkks, squareCipher)
ckksEvaluator.relinearize(squareCipher, ckksRelinKeys, squareCipher)
ckksEvaluator.rescaleToNext(squareCipher, squareCipher)
const ckksPlain = seal.PlainText()
ckksDecryptor.decrypt(squareCipher, ckksPlain)
const decodedCkks = ckksEncoder.decode(ckksPlain, scale)
console.log('CKKS乘法近似结果:', Array.from(decodedCkks))
运行后得到的并不是精确的0.25, 1.5625, 7.5625, 9.61,而是非常接近的近似值。误差通常在10的负8次方到10的负12次方量级,具体取决于scale和参数选择。如果业务对误差极度敏感,比如金融结算系统,CKKS可能不适合,应该优先选择BFV整数方案并控制数值范围。
CKKS的噪声预算会随每次重缩放而下降,当预算接近零时解密会得到无效结果。生产环境需要提前估算最大乘法深度,并保证系数模数链足够长。一般来说,系数模数每多一层,就能多支持一次乘法,同时密文也越大。调参时可以用小规模数据反复测试,记录输出误差和噪声预算变化趋势。
六、密文序列化与跨服务传输
同态加密的强大之处在于密文可以离开生成环境,在不可信节点上完成计算。要实现跨服务传输,就必须把密钥、密文和参数序列化成二进制数据。node-seal对象提供了save和load方法,序列化结果为Uint8Array,可以直接写入文件系统、Redis或消息队列。
const fs = require('fs')
const publicBytes = publicKey.save()
fs.writeFileSync('public.seal', Buffer.from(publicBytes))
const cipherBytes = ciphertext.save()
fs.writeFileSync('cipher.seal', Buffer.from(cipherBytes))
const loadedPublic = seal.PublicKey()
loadedPublic.load(context, new Uint8Array(fs.readFileSync('public.seal')))
const loadedCipher = seal.Ciphertext()
loadedCipher.load(context, new Uint8Array(fs.readFileSync('cipher.seal')))
const plainAfterLoad = seal.PlainText()
decryptor.decrypt(loadedCipher, plainAfterLoad)
console.log('从文件加载后解密:', Array.from(encoder.decode(plainAfterLoad)))
反序列化时必须使用完全相同的上下文参数,否则load操作会因参数不匹配而失败。最佳实践是把加密参数单独序列化并存档,在加载任何对象前先还原参数并创建Context。私钥的序列化数据应使用AES或云KMS进行二次加密,避免磁盘泄露或备份泄露造成严重后果。
跨服务传输时还需要考虑完整性校验。密文虽然不会直接泄露明文,但攻击者可以篡改密文,导致解密后得到错误的业务数据。可以在传输前对密文序列化结果计算HMAC,接收方验证完整性后再反序列化。对于高敏感场景,建议使用TLS加密传输通道,并在解密服务入口处增加审计日志。
七、生产环境中的参数选择与性能建议
参数选择要在安全性、计算性能和噪声预算之间取得平衡。4096次数的多项式模数通常能提供约128位的安全级别,适合大多数中等规模计算;8192次数更安全,但同态乘法和加密操作的时间会成倍增加。系数模数链越长,噪声预算越充足,但密文体积和计算开销也越大。BFV的明文模数一旦过小,整数运算就会溢出,所以业务数值上界必须提前评估。
Node.js调用node-seal本质上是执行CPU密集型的WebAssembly代码,这会阻塞事件循环。如果同态计算频繁发生,建议把计算任务放进worker_threads工作线程,或者单独部署一个计算服务,通过消息队列与主服务通信。多核服务器可以启动多个工作线程,每个线程持有独立的SEAL上下文和公钥,实现并行处理。
同态加密带来的计算开销通常是明文计算的几十倍到几百倍,不适合每秒钟处理海量请求。它更适合计算量相对不大但隐私要求极高的场景,例如跨机构风控联合建模、医疗数据合规分析、隐私保护的广告点击归因等。落地前先用真实业务数据做基准测试,记录加密、运算、解密各阶段耗时和内存占用,再决定是否需要购买更高主频的CPU或增加计算节点。
最后要注意密钥生命周期管理。私钥应定期轮换,轮换后旧密文可能无法解密,需要设计平滑的过渡方案。如果采用多方计算或联邦学习场景,还要明确各方的密钥使用权限、日志留存策略和应急吊销流程,避免私钥扩散带来的法律风险。
同态加密Microsoft SEALNode.js修改时间:2026-10-03 08:53:20