fhevm 网关重加密(Reencryption)机制详解:从链上密文获取到客户端私有解密全流程
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
重加密(Reencryption)是 fhevm 中面向个人用户私有数据的解密机制:dApp 先从合约的 view 函数取回密文句柄,再调用网关(Relayer)服务,将原本在链上 FHE 密钥下加密的密文重新加密到用户自己的公钥之下,最后仅由用户本人用私钥在本地解密。本文以 coprocessor/docs/fundamentals/gateway/reencryption.md 为骨架,结合 sdk/js-sdk/docs/decryption.md、docs/sdk-guides/user-decryption.md、relayer 与 gateway-contracts 等源码,讲透其流程、原理与可复制的客户端实现代码。
一、什么是重加密(Reencryption)?
在 fhevm 中,链上的所有数值默认以密文(ciphertext)形式存储,任何人都拿不到明文。但"任何人都看不到"并不总是对的:对于一个盲拍应用,拍卖结束后需要公开获胜者;而对于一个机密 ERC20 的余额查询,只有用户本人应当看到自己的余额。
这两类需求对应两种完全不同的解密路径:
| 解密类型 | 可见范围 | 触发方 | 用途示例 |
|---|---|---|---|
| 公开解密(Public/Async Decryption) | 所有人可见 | 合约事件 → 网关作为预言机回调 | 拍卖揭晓获胜者、公开计算结果 |
| 重加密 / 用户解密(Reencryption / User Decryption) | 仅授权用户本人可见 | dApp 客户端调用网关 | 查询个人余额、个人计数器、私人档案字段 |
正如 decryption.md 开头明确警告的那样:
Decryption is public: It means everyone will be able to see the value. If this is a personal information see Reencryption.
也就是说,一旦走公开解密,所有人都会看到明文;如果数据属于个人隐私,就必须走重加密。公开解密的流程是"网关监听链上解密请求事件 → 计算存储证明 → 从 KMS 取回明文 → 通过回调函数返回",其结果由网络私钥解密,人人可见;而重加密的流程是"客户端发起、用户持有解密私钥",这正是本文的主题。
术语说明
需要注意,本仓库的术语经历了演进:sdk/js-sdk/docs/GLOSSARY.md 明确将reencrypt、reencryption、user decrypt标记为废弃术语,而 sdk/js-sdk/docs/migration.md 给出的迁移对照为:
| 旧术语 | 新术语 |
|---|---|
userDecrypt/ reencrypt / reencryption | decryptValue/decryptValues |
因此在阅读本文与仓库代码时,"重加密""用户解密""decryptValue"指的是同一条技术链路。
二、重加密的整体流程:四个核心步骤
原文档用四步概括了整个重加密流程,这也是 dApp 集成的核心链路:
重加密在客户端完成,通过 fhevmjs 库调用网关服务完成。前提是你需要提供一个返回待重加密密文的 view 函数。
- dApp 从 view 函数取回密文(例如
balanceOf)。 - dApp 为用户生成一对密钥,并请求用户对该公钥进行签名。
- dApp 调用网关,提交密文、公钥、用户地址、合约地址以及用户签名。
- dApp 用私钥解密网关返回的结果。
下面把每一步结合仓库源码与 SDK 文档展开。
第 1 步:通过 view 函数取回密文句柄
链上的密文以bytes32句柄(handle)的形式存在,合约需要提供一个 view 函数把句柄暴露给客户端。典型写法如下(来自 docs/sdk-guides/user-decryption.md):
import "@fhevm/solidity/lib/FHE.sol"; contract ConfidentialERC20 { ... function balanceOf(account address) public view returns (euint64) { return balances[msg.sender]; } ... }balanceOf返回的是密文句柄(ciphertext handle),它是指向底层密文的标识符,而不是明文。客户端拿到句柄后,可以以多种形态传给解密接口(EncryptedValueLike):十六进制字符串、32 字节的Uint8Array,或EncryptedValue句柄对象。
仓库中 host-contracts/examples/Reencrypt.sol 提供了一个专门演示重加密的完整示例合约,覆盖了ebool、euint8/16/32/64/128/256、eaddress等全部 FHE 数据类型,并在构造函数里逐一执行了权限设置:
xBool = FHE.asEbool(true); FHE.allowThis(xBool); // 允许合约本身(网关校验时需要) FHE.allow(xBool, msg.sender); // 允许部署者解密这里的FHE.allow是重加密能否成功的关键前置条件,详见下文"ACL 权限"小节。
第 2 步:生成用户密钥对并让用户签名
dApp 在本机(浏览器或 Node 进程)为用户生成一对密钥。私钥永不离开用户设备,公钥则被嵌入一条 EIP-712 签名消息,由用户(即数据所有者)用钱包签名,以此授权"允许把指定合约中的密文重加密到该公钥之下"。
在新版 SDK(@fhevm/sdk)中,这一步对应generateTransportKeyPair:
const transportKeyPair = await client.generateTransportKeyPair(); transportKeyPair.publicKey; // BytesHex —— 可安全对外发送,会被嵌入签名许可 // 私钥由 SDK 内部持有,绝不出现在该对象上其底层实现在 sdk/js-sdk/src/core/actions/decrypt/generateTransportKeyPair.ts,核心是调用 KMS 模块的generateTransportKeyPair_生成传输密钥对。该密钥对属于"传输密钥":KMS 会用其公钥部分加密结果,只有持有对应私钥的客户端才能还原明文。
在经典zama-fhe/relayer-sdk中,这一步对应instance.generateKeypair()(详见下文第五节的完整示例)。
第 3 步:调用网关提交重加密请求
dApp 将以下信息打包发给网关服务:
- 密文句柄与合约地址(可能多对,构成
handleContractPairs); - 用户公钥;
- 用户地址;
- 用户对请求的 EIP-712 签名。
在网关侧,这份请求体的字段被严格校验。见 relayer/src/http/endpoints/v2/types/user_decrypt.rs 中UserDecryptRequestJson的定义:
| 字段 | 含义 | 校验规则(源码中可见) |
|---|---|---|
handleContractPairs | 待解密的密文句柄 + 合约地址对 | 非空,且每个合约地址合法 |
requestValidity | 请求的有效期信息 | 自定义校验 |
contractsChainId | 密文所在链的链 ID | 合法链 ID 字符串 |
contractAddresses | 本许可覆盖的合约地址列表 | 至少 1 个,均为合法区块链地址 |
userAddress | 请求解密的用户以太坊地址 | 0x+ 40 位十六进制字符 |
signature | 用户对请求的 EIP-712 签名 | 恰好 130 字符的原始十六进制、不带0x前缀(即 65 字节的 r/s/v) |
publicKey | 用户用于重加密的公钥 | 原始十六进制、不带0x前缀 |
extraData | 附加数据(用于上下文校验) | 自定义校验 |
网关(Relayer)收到请求后,会进入user_decrypt_handler流程(见 relayer/src/gateway/user_decrypt_handler.rs),把请求组装成链上调用并发送给网关链上的 Decryption.sol 合约的userDecryptionRequest/delegatedUserDecryptionRequest函数,等待 KMS 共识后将结果以userDecryptionResponse写回链上,再由网关回传给客户端。整个过程保证了:
- 密文始终处于加密状态,任何中间方(包括网关/Relayer 本身)都看不到明文;
- 明文的最终接收者由用户签名与 ACL 权限双重约束。
第 4 步:客户端本地解密
网关返回的是一份"在用户公钥下重新加密的密文"。dApp 用第 2 步生成的私钥在本地完成最终解密,得到明文。明文从头到尾没有以任何明文形式在网络中传输。
三、重加密与公开解密的分工:网关在两种模式下的角色
如果把重加密放在整个 fhevm 网关体系中看,它与公开解密(Async Decryption)共享了同一套"网关 = 预言机服务"的基础设施,但走向完全不同:
- 公开解密:合约发出解密请求事件 → 网关计算存储证明、从 FHEVM 取回密文 → 向 KMS 发起解密 → 通过回调函数把明文公开返回。适用于"结果本身就该公开"的场景。
- 重加密/用户解密:dApp 在客户端发起 → 网关携带用户公钥与签名去请求 KMS 将明文用用户公钥重新加密 → 结果只有用户能解。适用于"结果只属于某个人"的场景。
用户解密链路由Relayer与KMS(密钥管理系统)共同完成(见 docs/sdk-guides/user-decryption.md):从链上取回密文后,数据被 KMS 解密、再用用户的公钥重新加密,从而保证"数据始终处于加密状态,却能被安全地分享给指定用户"。关于 KMS 内部机制(中心化 / 阈值 / 区块链等形态),可进一步阅读 tkms 架构文档。
四、前置条件:ACL 权限(容易被忽略的关键步骤)
重加密不是"任何人对任何密文都能解"。合约持有者必须先用 ACL 放行,否则网关会拒绝请求。仓库中的示例合约(Reencrypt.sol)体现了两条必须同时满足的授权:
FHE.allowThis(xUint32); // 1. 允许合约自身(网关/链上校验需要) FHE.allow(xUint32, msg.sender); // 2. 允许指定用户解密该密文完整规范见 ACL 文档。SDK 层面也提供了"先预检、再解密"的接口canDecryptValue/canDecryptValues/canDecryptValuesFromPairs,它们返回{ allowed, details },在权限不满足时不会抛异常,适合用来在 UI 上提前禁用"解密/查看"按钮(详见 sdk/js-sdk/docs/decryption.md):
import { canDecryptValue } from '@fhevm/sdk/actions/decrypt'; const { allowed, details } = await canDecryptValue(client, { encryptedValue, contractAddress, signedPermit, // 或 userAddress: '0x…' }); allowed; // boolean details.contractAllowed; // 合约是否被允许持有该值 details.userAllowed; // 用户是否被允许解密它五、客户端实操:两种 SDK 的完整写法
5.1 经典写法:@zama-fhe/relayer-sdk
这是 docs/sdk-guides/user-decryption.md 给出的完整客户端流程(使用前需先按 初始化文档 创建FhevmInstance):
// instance: [`FhevmInstance`] from `@zama-fhe/relayer-sdk` // signer: [`Signer`] from ethers(可以是 [`Wallet`]) // ciphertextHandle: [`string`] —— 第 1 步从 view 函数取回的句柄 // contractAddress: [`string`] // 第 2 步:生成用户密钥对(私钥留在本地) const keypair = instance.generateKeypair(); const handleContractPairs = [ { handle: ciphertextHandle, contractAddress: contractAddress, }, ]; // 构造 EIP-712 许可:公钥 + 合约列表 + 有效期 const startTimeStamp = Math.floor(Date.now() / 1000).toString(); const durationDays = "10"; // 字符串形式,保持一致 const contractAddresses = [contractAddress]; const eip712 = instance.createEIP712(keypair.publicKey, contractAddresses, startTimeStamp, durationDays); // 第 2 步后半:请求用户签名(这里会弹出钱包签名) const signature = await signer.signTypedData( eip712.domain, { UserDecryptRequestVerification: eip712.types.UserDecryptRequestVerification, }, eip712.message, ); // 第 3 步:调用网关(Relayer)发起用户解密 const result = await instance.userDecrypt( handleContractPairs, // 密文句柄 + 合约地址对 keypair.privateKey, // 用户私钥(用于解密返回结果) keypair.publicKey, // 用户公钥(用于重加密) signature.replace("0x", ""), // 去掉 0x 前缀,与网关侧 130 字符校验对应 contractAddresses, // 许可覆盖的合约列表 signer.address, // 用户地址 startTimeStamp, durationDays, ); // 第 4 步:按句柄取出解密后的明文 const decryptedValue = result[ciphertextHandle];5.2 新写法:@fhevm/sdk(推荐)
新 SDK 把流程重构为"传输密钥对 + 签名许可 + 解密"三段式,API 更清晰,且支持批量与委托解密。以下示例综合自 sdk/js-sdk/docs/decryption.md 与 sdk/js-sdk/docs/actions.md。
Step 1 — 生成传输密钥对:
const transportKeyPair = await client.generateTransportKeyPair();Step 2 — 签署解密许可(EIP-712):
许可有新旧两个版本,参数完全一致:
signLegacyDecryptionPermit:V1 EIP-712 形态(协议 v13 及以下),兼容所有部署,默认选择;signUnifiedDecryptionPermit:V2 统一 EIP-712 形态(协议 v14+),需要链上 KMSVerifier/ProtocolConfig 已升级,可用canUseUnifiedDecryptionPermit先探测。
import { canUseUnifiedDecryptionPermit } from '@fhevm/sdk/actions/base'; const now = Math.floor(Date.now() / 1000); const params = { transportKeyPair, contractAddresses: ['0xYourContract…'], startTimestamp: now, durationSeconds: 7 * 24 * 60 * 60, // 注意单位是秒:7 天 signerAddress: await signer.getAddress(), signer, // ethers Signer,或 viem Account / WalletClient }; const signedPermit = (await canUseUnifiedDecryptionPermit(client)) ? await client.signUnifiedDecryptionPermit(params) : await client.signLegacyDecryptionPermit(params);| 参数 | 类型 | 说明 |
|---|---|---|
transportKeyPair | TransportKeyPair | 第 1 步产物,其公钥被绑定进许可 |
contractAddresses | readonly string[] | 该许可授权解密的全部合约 |
startTimestamp | number | Unix 秒级时间戳,许可生效时刻 |
durationSeconds | number | 有效期长度,单位是秒(一周应传7 * 24 * 60 * 60) |
signerAddress | string | 签名者地址,通常即数据所有者 |
signer | 原生 signer | ethersSigner/ viemAccount或WalletClient |
delegatorAddress | string(可选) | 委托解密时填写,见下文 |
值得一提的两个细节(来自 sdk/js-sdk/docs/decryption.md):signerAddress可以是智能合约钱包(如 Safe),SDK 会自动走 ERC-1271isValidSignature校验签名;而旧接口client.signDecryptionPermit已标记@deprecated,只是signLegacyDecryptionPermit的别名,新代码应显式调用上述两个签名接口。签名许可可复用:签一次,在有效期内可对列出的所有合约解密任意多个值,直到过期(signedPermit.assertNotExpired()会校验)。
Step 3 — 解密:
const decrypted = await client.decryptValue({ transportKeyPair, encryptedValue, // bytes32 句柄,从合约 getter 读到的值 contractAddress: '0xYourContract…', signedPermit, }); decrypted.value; // 42(number)、1000n(bigint)、true、或 "0x…"(address) decrypted.type; // "uint32"、"bool"、"address" …(Solidity 值类型名)结果是一个TypedValue,加密类型到 JS 类型的映射关系如下:
| 加密类型 | type | value |
|---|---|---|
euint8/16/32 | 对应类型名 | number |
euint64/128/256 | 对应类型名 | bigint |
ebool | 'bool' | boolean |
eaddress | 'address' | 校验和格式的字符串 |
明文由 KMS 份额在本地重建,全程不出现明文传输。客户端的动作封装位于 sdk/js-sdk/src/core/clients/decorators/decrypt.ts,其暴露的动作包括decryptValue、decryptValues、decryptValuesFromPairs与generateTransportKeyPair。
5.3 批量解密、委托解密与会话持久化
批量解密(避免每个值都签名、都发起网络往返):
// 同一合约的多个值: const results = await client.decryptValues({ transportKeyPair, contractAddress: '0xYourContract…', encryptedValues: [handleA, handleB, handleC], signedPermit, }); // 跨合约的多个值: const results = await client.decryptValuesFromPairs({ transportKeyPair, pairs: [ { encryptedValue: handleA, contractAddress: '0xContractA…' }, { encryptedValue: handleB, contractAddress: '0xContractB…' }, ], signedPermit, // 必须列出上面引用的每一个合约 });两者都按输入顺序返回readonly TypedValue[]。
委托解密:允许一个账户代表另一个账户解密。用委托方的signer签署许可,并在delegatorAddress中指明数据所有者:
const signedPermit = await client.signLegacyDecryptionPermit({ transportKeyPair, contractAddresses: ['0xYourContract…'], startTimestamp: now, durationSeconds: 24 * 60 * 60, signerAddress: delegateAddress, // 委托方签名 signer: delegateSigner, delegatorAddress: ownerAddress, // 被解密的数据所有者 });生成的许可会标记isDelegated: true。注意:委托关系必须已在链上 ACL 中授权,签署许可只是授权本次请求。这条能力在网关侧也有对应的DelegatedUserDecryptRequestJson结构(见 user_decrypt.rs)与链上delegatedUserDecryptionRequest函数(见 Decryption.sol)。
会话持久化:传输密钥对和签名许可都可以序列化/反序列化,以便用户下次访问时不必重新签名:
const kp = await client.serializeTransportKeyPair({ transportKeyPair }); const permit = await client.serializeSignedDecryptionPermit({ signedPermit }); // 将 kp 与 permit 持久化(如 localStorage) // 恢复: const transportKeyPair = await client.parseTransportKeyPair(kp); const signedPermit = await client.parseSignedDecryptionPermit({ serializedPermit: permit, transportKeyPair, });⚠️ 序列化后的传输密钥对包含私钥,必须视为机密:不要写进日志、不要拼进 URL、不要发给服务器,只能存放在保存会话密钥的地方。
六、重加密链路一图总览
把四个步骤与仓库各模块对应起来,完整链路如下:
┌─────────────┐ ① 调用 view 函数 ┌──────────────────┐ │ dApp │ ──────────────────────────▶ │ 链上合约(密文) │ │ (浏览器/Node)│ │ e.g. Reencrypt │ └─────────────┘ └──────────────────┘ │ │ ② 生成传输密钥对 + 用户签署 EIP-712 许可 │ ③ 提交 {密文句柄, 合约地址, 公钥, 用户地址, 签名} ▼ ┌─────────────────────┐ userDecryptionRequest ┌──────────────────┐ │ 网关 / Relayer │ ───────────────────────────▶ │ Decryption.sol │ │ user_decrypt_handler │ ◀─────────────────────────── │ userDecryption │ └─────────────────────┘ userDecryptionResponse │ Response / KMS │ │ └──────────────────┘ │ ④ 返回"用用户公钥重新加密"的结果 ▼ ┌─────────────┐ │ 用户本地私钥 │ —— 最终解密得到明文,明文全程不出设备外 └─────────────┘关键实现位置索引:
- 网关 HTTP 请求结构与校验:relayer/src/http/endpoints/v2/types/user_decrypt.rs
- 网关侧处理流水线:relayer/src/gateway/user_decrypt_handler.rs
- 链上请求/响应合约:gateway-contracts/contracts/Decryption.sol
- 客户端动作封装:sdk/js-sdk/src/core/clients/decorators/decrypt.ts
- 演示合约(含 ACL 授权):host-contracts/examples/Reencrypt.sol
七、总结
重加密是 fhevm 面向隐私数据的核心能力:它以"密文始终加密、私钥永不离开用户"为原则,把链上 FHE 密钥下的密文安全地转移到用户自己的密钥体系下。集成时只需遵循四个步骤——取句柄、生成密钥对并签名、调用网关、本地解密——并牢记两个关键点:先在合约里用FHE.allow配好 ACL,在客户端妥善保管传输私钥。建议在 sdk/js-sdk/docs/decryption.md 与 docs/sdk-guides/user-decryption.md 的基础上,结合 Reencrypt.sol 示例合约和 test-suite/e2e 中的相关测试进行端到端验证。
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考