安全多方计算这几年在隐私计算圈子里热度一直不低,而加法秘密共享几乎是所有实用化方案的起点。很多人第一次接触秘密共享,是从"把秘密拆成几份,凑齐才能还原"这个直觉开始的,但真正落到工程里,最让人挠头的往往不是拆分本身,而是拆完之后怎么在密文状态下做乘法。我前后在几个隐私求交、联邦统计的项目里反复用过这套东西,踩过的坑不算少,这里把加法秘密共享以及加法共享上的乘法运算从头到尾捋一遍,包括原理、参数选择、实操步骤和排查经验,尽量让刚入门的人也能跟着复现,做过一阵子的人也能找到几个容易忽略的细节。
1. 加法秘密共享的整体设计与思路拆解
1.1 为什么是"加法"而不是别的拆分方式
秘密共享的家族里,常见的有Shamir多项式共享、中国剩余定理共享,还有最朴素的加法共享。Shamir的好处是门限灵活,比如3个参与方里任意2个就能还原,但代价是每次运算都要处理多项式插值,乘法还需要做降次,工程实现复杂度高。加法共享的逻辑则简单到近乎粗暴:一个秘密值x,在模p的整数环上随机切成若干份,所有份额相加模p等于x。要还原就所有份额相加,少一份都还原不出来。
我选加法共享作为主力方案,核心原因是它和后续的乘法协议配合得最自然。加法共享天然支持加法同态——两个共享值相加,各参与方本地把份额相加即可,不需要任何通信。这个性质在统计类任务里太重要了,求和、求均值、算频次,全是加法,本地一加就完事,通信开销几乎为零。相比之下,Shamir共享做加法虽然也能本地完成,但份额结构更重,传输和存储都更占资源。
另一个现实考量是参与方数量。加法共享在"两方"场景下特别干净,一方持有份额a,另一方持有份额b,a+b=x。两方乘法协议(比如经典的Beaver三元组方案)在加法共享上的表达非常简洁。而Shamir在少量参与方时反而显得杀鸡用牛刀。所以如果你的场景是两方或者少量固定参与方,加法共享基本是首选。
1.2 加法共享到底解决了什么问题
一句话概括:它让多个互不信任的参与方能够在不暴露各自原始数据的前提下,共同完成计算。举个具体场景,两家机构想统计各自用户的平均消费金额,但谁都不愿意把用户明细给对方。用加法共享,双方各自把数据拆成两份,互相交换一份,然后本地相加得到共享的中间结果,最后通过安全协议还原出总和与总数,整个过程没有任何一方看到对方的原始数据。
这里的关键在于"互不信任"四个字。如果参与方之间完全信任,那直接把数据汇总就行,根本不需要秘密共享。秘密共享的价值恰恰在于它把信任需求降到了最低——你只需要相信协议本身,不需要相信对方的人品。这也是为什么它在隐私计算、联邦学习、安全拍卖这些领域被反复使用。
1.3 方案选型的几个关键权衡
实际落地时,加法共享有几个绕不开的权衡点,我列个表对比一下,方便你根据场景做决定。
| 权衡维度 | 加法共享的表现 | 需要注意的地方 |
|---|---|---|
| 通信开销 | 加法零通信,乘法需要交互 | 乘法是主要瓶颈 |
| 计算复杂度 | 加法极低,乘法依赖三元组 | 三元组预生成有成本 |
| 参与方数量 | 两方最自然,多方需扩展 | 多方乘法协议更复杂 |
| 安全性假设 | 半诚实模型下很成熟 | 恶意模型需额外校验 |
| 实现难度 | 低,适合快速原型 | 生产环境要处理边界 |
半诚实模型和恶意模型的区别这里要强调一下。半诚实模型假设参与方会老老实实按协议执行,只是会偷看中间数据;恶意模型则假设参与方可能篡改消息、伪造份额。绝大多数加法共享的入门实现都是半诚实模型,如果你要做生产系统,必须明确你的威胁模型,否则安全性论证是不成立的。我见过不少项目直接拿半诚实的代码上生产,这是很危险的。
2. 加法秘密共享的核心细节与实操要点
2.1 份额拆分:模数选择是第一道坎
拆分本身很简单,但模数p的选择直接决定了后续所有运算的正确性。常见的选择有两类:一类是素数模,比如一个大素数;另一类是2的幂次模,比如2^64。这两者各有讲究。
用素数模的好处是它构成一个有限域,除法、求逆这些运算都有良好定义,很多密码学协议默认在域上工作。用2的幂次模的好处是它能直接对应计算机的整数运算,溢出行为天然就是模运算,实现起来快,而且和定点数编码配合得好。我在做机器学习相关的安全计算时,基本都用2的幂次模,因为要处理浮点数的定点化表示,用素数模反而要额外做编码转换。
拆分代码大概长这样,以两方为例:
import secrets def split_additive(secret, p, n_parties=2): # 生成 n-1 个随机份额 shares = [secrets.randbelow(p) for _ in range(n_parties - 1)] # 最后一份用 secret 减去前面所有份额 last = (secret - sum(shares)) % p shares.append(last) return shares def reconstruct_additive(shares, p): return sum(shares) % p这段代码里有个细节值得说:最后一份是用减法算出来的,不是随机生成的。这是为了保证所有份额之和严格等于秘密值。如果你图省事把每一份都随机生成,那还原出来的就不是原秘密了。这个错误我在早期实现里犯过,调试了半天才发现。
注意:随机数的质量直接决定安全性。用语言自带的伪随机函数(比如某些场景下的random模块)是不够的,必须用密码学安全的随机源,Python里就是secrets模块。伪随机数可预测,攻击者一旦预测出随机份额,就能反推出秘密。
2.2 份额的存储与传输格式
份额本质上就是一个大整数,但工程上怎么存、怎么传有讲究。如果模数是2^64,那每个份额正好是一个64位无符号整数,可以直接用定长字节序列表示,传输效率最高。如果模数是素数,份额的位长取决于素数大小,通常要按固定字节数对齐,避免解析时出错。
我一般会把份额序列化成固定长度的字节串,比如模数小于2^64就统一用8字节大端序。这样接收方解析时不用猜长度,直接按8字节切分即可。跨语言交互时尤其要注意字节序,我遇到过Python端按小端序打包、C++端按大端序解析,结果份额全错的情况,排查了很久才定位到。
传输层面,份额的机密性依赖于通道安全,但份额本身不应该再被加密一层——因为份额已经是秘密的随机化表示,再加密纯属浪费。不过份额的完整性需要保护,防止传输中被篡改,通常用消息认证码(MAC)来保证。这一点在半诚实模型下可以省略,但恶意模型下必须加。
2.3 加法同态的实操验证
加法同态是加法共享最实用的性质,验证起来也简单。假设两方分别持有x和y的份额,想计算x+y的共享。做法是:每一方把自己持有的x份额和y份额本地相加,得到新的份额。所有新份额之和就等于x+y。
# 参与方1持有 x1, y1;参与方2持有 x2, y2 # 计算 x+y 的共享 new_share_1 = (x1 + y1) % p new_share_2 = (x2 + y2) % p # 验证:new_share_1 + new_share_2 == (x+y) % p这个操作零通信,纯本地计算,速度快到可以忽略。我在做大规模统计时,几百万条记录的求和就是靠这个性质,各参与方本地累加自己的份额,最后只还原一次总和,通信量从O(n)降到O(1)。这是加法共享相比其他方案最爽的地方。
但要注意,加法同态只对"加法"成立,减法也可以(减去一个份额等价于加上它的负元),但乘法和除法不行。这就是为什么乘法需要单独的协议,也是下一节的重点。
3. 加法共享上的乘法运算实现
3.1 乘法为什么不能本地完成
先想清楚一个问题:为什么两个加法共享相乘不能像加法那样本地搞定?假设x被拆成x1+x2,y被拆成y1+y2。如果双方各自本地计算x1y1和x2y2,然后相加,得到的是x1y1 + x2y2,而我们要的是(x1+x2)(y1+y2) = x1y1 + x1y2 + x2y1 + x2y2。差了交叉项x1y2 + x2*y1。
问题就出在交叉项上。x1在参与方1手里,y2在参与方2手里,参与方1算不出x1*y2,因为它不知道y2;参与方2同理。所以乘法必须引入通信,让双方能协作算出交叉项,同时又不暴露各自的份额。这就是所有安全乘法协议的核心难点。
3.2 Beaver三元组:最经典的乘法方案
Beaver三元组是解决这个问题的经典方法,思路非常巧妙。核心思想是预先准备一组"乘法三元组"(a, b, c),满足c = a*b,其中a、b、c都以加法共享的形式分散在各参与方手里。注意,这个三元组是提前生成的,与要计算的x、y无关。
有了三元组,计算x*y的共享分几步走:
第一步,双方各自计算d = x - a和e = y - b的共享。因为x、a都是加法共享,减法可以本地完成,得到d和e的共享。
第二步,双方互相公开d和e的值(注意是公开d和e,不是公开x和y)。这一步需要通信,双方交换各自的d份额和e份额,然后各自相加得到完整的d和e。
第三步,双方本地计算z = c + db + ea + d*e的共享。这里c、a、b都是共享形式,d和e是公开值,所以每一项都能本地算出来。
为什么这样能得到xy?展开验证一下:x = a + d,y = b + e,所以xy = (a+d)(b+e) = ab + ae + db + de = c + ae + db + de。完全对上了。
关键在于,公开d和e不会泄露x和y,因为a和b是随机的、参与方不知道的。d = x - a,只要a足够随机,d就是均匀分布的,看不出x的任何信息。这就是三元组的安全性来源。
3.3 三元组的预生成与消耗管理
三元组不能临时生成,因为生成三元组本身就需要安全乘法(要保证c = a*b且各方不知道a、b),这是个鸡生蛋的问题。实际做法是提前用其他手段生成一大批三元组,存起来备用。生成方式有几种:一种是用同态加密,一种是用不经意传输,还有一种是用可信第三方直接分发。
我在项目里最常用的是基于不经意传输的方案,因为它不依赖额外的密码学假设,纯靠对称密码学原语就能搞定,速度快。生成一批三元组后,存到一个池子里,每次需要乘法就从池子里取一个,用完即弃。这里有个重要的安全原则:三元组绝对不能重复使用。一旦同一个三元组被用于两次乘法,攻击者就能通过两次公开的d、e值联立方程,反推出a、b,进而威胁到x、y的隐私。
所以三元组的消耗管理很关键。我一般会预估整个计算过程需要多少次乘法,然后多生成20%到30%的冗余量,防止中途不够用。如果计算过程中三元组耗尽,要么暂停重新生成(会阻塞),要么提前分批生成。生产系统里,三元组的生成速率往往决定了整个安全计算的吞吐上限,这是性能优化的重点。
| 三元组生成方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 不经意传输 | 无额外假设,速度快 | 实现较复杂 | 两方高频计算 |
| 同态加密 | 概念直观 | 计算开销大 | 低频或原型验证 |
| 可信第三方分发 | 实现最简单 | 引入信任假设 | 内部可信环境 |
3.4 完整乘法流程的代码实现
把上面的步骤串起来,两方Beaver乘法的大致实现如下:
def beaver_multiply(x_shares, y_shares, triple, p): # x_shares = [x1, x2], y_shares = [y1, y2] # triple = ([a1,a2], [b1,b2], [c1,c2]) a_shares, b_shares, c_shares = triple # 第一步:本地计算 d = x - a, e = y - b 的份额 d_shares = [(x_shares[i] - a_shares[i]) % p for i in range(2)] e_shares = [(y_shares[i] - b_shares[i]) % p for i in range(2)] # 第二步:互相公开 d 和 e,双方都能得到完整的 d, e d = (d_shares[0] + d_shares[1]) % p e = (e_shares[0] + e_shares[1]) % p # 第三步:本地计算 z = c + d*b + e*a + d*e 的份额 z_shares = [] for i in range(2): zi = (c_shares[i] + d * b_shares[i] + e * a_shares[i]) % p z_shares.append(zi) # 注意 d*e 是公开值,双方都加上,但只能加一次 z_shares[0] = (z_shares[0] + d * e) % p return z_shares这里有个容易出错的细节:de是公开值,双方都知道,但它在最终的共享里只能出现一次。如果双方都加上de,结果就会多算一份。我的处理方式是只让参与方1加上de,参与方2不加。这样z_shares[0] + z_shares[1] = c + db + ea + de,正好等于xy。这个坑我在第一次实现时踩过,还原出来的结果总是偏大,查了好久才发现是de加重复了。
提示:de的归属问题在多方场景下更微妙。n方乘法时,de只能由其中一方承担,或者按某种约定分摊,绝不能每方都加。建议在协议文档里明确写清楚,避免实现分歧。
4. 常见问题与排查技巧实录
4.1 还原结果不对的排查思路
乘法实现完,第一次跑出来结果不对是常态。我总结了一套排查顺序,基本能覆盖九成以上的问题。
先查模数一致性。所有参与方的模数p必须完全相同,包括三元组生成时用的模数。我遇到过三元组用2^64生成、乘法用素数模计算的情况,结果自然全错。模数不一致是最隐蔽的bug之一,因为单看每一方的代码都没问题。
再查de的加法次数。前面说过,de只能加一次。如果结果偏大或偏小一个de,基本就是这个原因。可以打印中间值验证:单独算一次xy,和协议算出来的对比,差值如果是d*e,那就定位到了。
然后查三元组是否满足c = ab。三元组生成环节如果有bug,c和ab对不上,整个乘法就崩了。建议在生成三元组后加一个自检:随机抽取几个三元组,还原a、b、c,验证c == a*b % p。这个自检成本很低,但能拦住大量问题。
最后查份额的符号处理。模运算下减法要加p再取模,如果直接相减得到负数,后续运算就会出错。Python的%运算符对负数会返回正余数,但C++等语言不一定,跨语言时尤其要注意。
4.2 性能瓶颈的定位与优化
安全乘法的性能瓶颈通常在两个地方:三元组生成和网络通信。定位方法很简单,分别计时。如果三元组生成占了大部分时间,那就优化生成环节,比如用更快的密码学原语,或者预生成更多三元组做缓存。如果通信占大头,那就考虑批量处理——把多个乘法打包,减少往返次数。
我做过一个优化,把1000次乘法需要的d、e值批量交换,而不是每次乘法都通信一次。这样网络往返从1000次降到1次,整体速度提升了一个数量级。代价是需要缓存更多中间状态,内存占用上升,但在这个场景下完全值得。
另一个优化点是三元组的复用策略。虽然三元组不能重复用于不同的乘法,但可以预生成一个足够大的池子,用多线程并行生成,让生成和消耗重叠起来。这样计算过程不会因为等三元组而停顿。
4.3 安全性自查清单
功能跑通不代表安全,下面这份清单建议每次上线前过一遍。
| 检查项 | 合格标准 | 常见问题 |
|---|---|---|
| 随机数来源 | 密码学安全随机源 | 用了伪随机 |
| 三元组复用 | 绝不重复使用 | 为省事复用 |
| 模数一致性 | 全局统一 | 混用不同模数 |
| 中间值公开范围 | 只公开d、e | 误公开份额 |
| 威胁模型 | 明确半诚实或恶意 | 模型不清 |
| 份额完整性 | 恶意模型下有MAC | 裸传份额 |
特别说一下"中间值公开范围"。Beaver协议里公开的是d和e,不是x、y,也不是a、b。我见过有人图方便直接把x的份额公开了,那等于把秘密拱手送人。公开的每一个值都要问一句:它泄露原始数据了吗?d = x - a,只要a随机且保密,d就是安全的。这个推理链条要能说清楚,否则不要上线。
4.4 多方场景下的额外坑
两方乘法相对干净,扩展到三方及以上时,问题会成倍增加。首先是三元组的生成,多方三元组需要所有参与方协作,通信轮数增加。其次是d、e的公开,多方场景下需要所有方都拿到完整的d、e,广播通信的开销不可忽视。最后是恶意模型下的校验,多方场景里只要有一方作恶,整个结果就可能被污染,需要额外的可验证机制。
我的建议是,如果参与方超过三个,认真评估是否真的需要秘密共享,或者是否可以用更简单的方案(比如可信执行环境)替代。秘密共享的通信复杂度随参与方数量增长很快,超过一定规模后,性能会变得难以接受。这不是协议本身的缺陷,而是分布式安全计算的固有代价。
5. 从加法共享到实际应用的落地建议
5.1 定点数编码:让秘密共享能算小数
加法共享原生只能处理整数,但实际业务里小数无处不在。解决办法是定点数编码:把小数乘以一个缩放因子,转成整数再共享。比如保留6位小数精度,就乘以10^6。计算完成后,还原结果再除以缩放因子。
这里有个精度陷阱。乘法会让缩放因子平方,两个各带10^6缩放的值相乘,结果带10^12缩放,如果模数不够大就会溢出。所以模数的位长要预留足够空间。以2^64模数为例,如果输入值本身占32位,缩放占20位,那乘法后可能超过64位,必须提前规划。我一般会算清楚最坏情况下的位宽需求,宁可模数选大一点,也不要中途溢出。
5.2 批量计算的组织方式
实际任务往往是批量乘法,比如矩阵乘法、多项式求值。这时候不要一个一个乘法单独走协议,而是把整个批次组织起来,共享三元组、批量通信。矩阵乘法尤其适合这种优化,因为大量乘法可以并行,通信可以合并。
我做过一个安全矩阵乘法的实现,把左矩阵的每一行和右矩阵的每一列做内积,内积里的所有乘法共享同一批通信轮次。这样通信次数从O(mnk)降到O(轮数),性能提升非常明显。代价是实现复杂度上升,需要仔细管理中间状态的生命周期。
5.3 与其他隐私计算技术的配合
加法共享不是孤立的,它经常和其他技术配合使用。比如和同态加密结合,用同态加密生成三元组,用加法共享做在线计算;或者和安全比较协议结合,实现隐私求交、隐私集合运算。理解加法共享在整个技术栈里的位置,比孤立地掌握它更重要。
我在项目里的经验是,加法共享适合做"计算层",负责高效的加减乘;同态加密适合做"预处理层",负责生成三元组这类需要复杂密码学操作的素材;不经意传输适合做"通信层",负责高效的数据交换。三者各司其职,组合起来才能撑起一个完整的隐私计算系统。
5.4 工程化落地的几个提醒
最后说几个工程上的实在建议。第一,协议实现要有完整的单元测试,尤其是边界情况,比如份额为0、模数边界、负数输入。第二,日志要记录关键中间值,但绝不能记录份额和秘密值,否则日志本身就成了泄露渠道。第三,性能测试要覆盖最坏情况,比如三元组耗尽、网络延迟高、参与方掉线。第四,版本管理要严格,协议一旦上线,任何改动都可能破坏兼容性,参与方之间的协议版本必须一致。
我个人在实际操作中的体会是,加法秘密共享的入门门槛很低,半天就能写出能跑的代码,但要做到生产级的安全和性能,需要关注的细节非常多。乘法协议尤其如此,Beaver三元组看起来简单,但三元组生成、消耗管理、d*e归属这些环节,每一个都有坑。建议新手先用小规模数据把整个流程跑通,验证正确性,再逐步优化性能和扩展参与方数量。不要一上来就追求高性能,正确性永远是第一位的。