基于 fhEVM JS SDK 的隐私解密实战指南:从传输密钥对到链上可验证的公钥解密
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
fhEVM(Fully Homomorphic Encryption EVM)将全同态加密引入区块链,而数据从密文回到明文的过程——解密——是整个闭环的最后一环,也是信任模型最敏感的一环。本文以 sdk/js-sdk/docs/decryption.md 为骨架,结合仓库中sdk/js-sdk/src的实际源码实现,系统讲解 fhEVM JS SDK(@fhevm/sdk)中两类解密(私有解密与公开解密)的完整流程:如何生成传输密钥对、签署 EIP-712 解密许可(Permit)、批量解密、委托解密、权限预检,以及如何将公开解密结果携带 KMS 签名上链验证,并给出会话持久化等生产级实践。
解密的两种信任模型
在 fhEVM 中,"解密"并不是一个统一操作,而是两种信任模型截然不同的操作。SDK 文档首先强调这一点,因为它决定了你在哪个客户端上、携带什么凭证、以什么方式获得明文:
- 私有解密(Private decryption):只把值揭示给被授权的用户本人。明文会被重新加密(re-encrypt)到只有该用户持有的密钥下,因此任何人都无法在传输过程中看到明文——包括 Relayer(中继服务)。它需要
createFhevmClient或createFhevmDecryptClient,即需要加载 TKMS(约 600 KB)WASM。 - 公开解密(Public decryption):读取合约已显式标记为"可公开解密"的值(例如密封拍卖的获胜出价),结果对所有人公开。它在任何客户端上都可用,包括最轻量的基础客户端
createFhevmBaseClient,不需要任何密码学 WASM。
这一区别在源码层面被固化。SDK 的客户端工厂按加载的 WASM 模块区分能力:clients.md 中的能力矩阵显示,createFhevmClient加载 TFHE(约 4.9 MB)+ TKMS(约 600 KB)支持加密与私有解密,createFhevmEncryptClient只加载 TFHE 仅支持加密,createFhevmDecryptClient只加载 TKMS 仅支持私有解密,而createFhevmBaseClient不加载任何 WASM——公开解密与签署 permit 在基础客户端上依然可用。因此,一个只读公开值的页面永远不应该下载 4.9 MB 的加密模块。
在客户端装饰器实现 src/core/clients/decorators/decrypt.ts 中可以看到,私有解密能力decryptValue、decryptValues、decryptValuesFromPairs与generateTransportKeyPair被统一挂载到DecryptActions,作为扩展模块注入客户端;而公开解密与 permit 签署则位于基础动作(@fhevm/sdk/actions/base)中,因此"每个客户端都有"。
私有解密:三步完成安全读取
读取一个私有加密值需要三样东西:
- 传输密钥对(transport key pair)——在应用内生成。KMS(Key Management System)用其公钥加密解密结果,只有你能还原明文;私钥永远不离开你的应用(浏览器或 Node 进程)。
- 已签名的解密许可(signed decryption permit)——由值的所有者签署的 EIP-712 消息,授权在有限时间窗口内对一组特定合约进行解密。
- 要解密的加密值(
bytes32handle)及其所属合约。
核心提示:permit 是可复用的。签署一次,就可以在其过期前对所列合约内的任意多个值反复解密,无需对每个值重新签名。
第一步:生成传输密钥对
const transportKeyPair = await client.generateTransportKeyPair(); transportKeyPair.publicKey; // BytesHex — 可安全发送;会被嵌入 permit // 私钥保存在对象内部,永不暴露从源码看,这一步的实际执行者是 src/core/actions/decrypt/generateTransportKeyPair.ts 与底层 src/core/kms/TransportKeyPair-p.ts:
- 密钥对内部由 TKMS(Tfhe KMS)WASM 生成,SDK 先调用
generateTkmsPrivateKey,再序列化私钥、导出公钥十六进制串,最后包装成TransportKeyPairImpl实例返回; - 私钥被闭包与
Symbol私有令牌双重保护(PRIVATE_TOKEN+ 隐藏的[GetTkmsPrivateKeyFn]方法),外部代码即使通过Object.getOwnPropertySymbols发现该 Symbol 也无法调用; - 类的
toJSON()被刻意设计为只输出公钥,防止JSON.stringify(keyPair)意外泄露私钥; - 每次生成都会记录
tkmsVersion,用于后续与链上 KMS 版本对齐。
decryptValue动作的实现(decryptValue.ts)也印证了密钥对的用途:它从signedPermit.encryptedDataOwnerAddress提取所有者地址,将(handle、合约地址、所有者地址)三元组送入 KMS 解密管线,最终明文由 KMS 份额在本地重建。
第二步:签署解密许可
SDK 提供两种 permit 变体,区别在于它们适用的协议版本,两者都在一步内完成 EIP-712 的构建与签名,参数完全相同:
signLegacyDecryptionPermit——V1 EIP-712 结构(协议 v13 及以下)。兼容所有部署;除非明确需要 V2,否则用它。signUnifiedDecryptionPermit——V2 统一 EIP-712 结构(协议 v14 及以上)。要求 SDK 为协议 API v0.14.0+,且链上的 KMSVerifier/ProtocolConfig 已升级到该版本——先用独立动作canUseUnifiedDecryptionPermit(来自@fhevm/sdk/actions/base)检查。
signer在这里传入——这是客户端唯一触碰钱包的地方。
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 | 来自第一步;其公钥被绑定进 permit。 |
contractAddresses | readonly string[] | 该 permit 授权解密的每个合约。 |
startTimestamp | number | Unix 秒级时间戳(1970-01-01 起)。permit 何时开始生效。 |
durationSeconds | number | 有效期长度,单位是秒。 |
signerAddress | string | 签署方地址——通常是值的所有者。 |
signer | 原生 signer | ethersSigner/ viemAccount或WalletClient。 |
delegatorAddress | string(可选) | 仅委托解密时出现——见下文。 |
警告:durationSeconds是秒而不是天。一周的 permit 要传7 * 24 * 60 * 60。
提示:signerAddress不一定是普通 EOA。如果是智能合约钱包(如 Safe),SDK 会在把签名发给 KMS 之前通过 ERC-1271(isValidSignature)验证结果签名,而非ecrecover——无需额外配置。这正符合 signLegacyDecryptionPermit.ts 中参数类型signer: NativeSigner的设计。
危险提示:client.signDecryptionPermit仍然存在但已@deprecated——它是signLegacyDecryptionPermit的别名。新代码应显式调用signLegacyDecryptionPermit或signUnifiedDecryptionPermit。底层实现 SignedDecryptionPermit-p.ts 也印证了这一点:公共封装signDecryptionPermit被刻意固定在 V1,注释明确说明是为了兼容尚未迁移的调用方。
permit 可复用:签一次,然后在过期前对所列合约内的多个值反复解密。signedPermit.assertNotExpired()在过期时会抛出异常。
第三步:解密
const decrypted = await client.decryptValue({ transportKeyPair, encryptedValue, // 从合约读到的 bytes32 handle contractAddress: '0xYourContract…', signedPermit, }); decrypted.value; // 42 (number)、1000n (bigint)、true 或 "0x…" (address) decrypted.type; // "uint32"、"bool"、"address"…(Solidity 值类型名)结果是TypedValue。加密类型到 JavaScript 类型的映射:
| 加密类型 | type | value |
|---|---|---|
euint8/16/32 | 对应类型 | number |
euint64/128/256 | 对应类型 | bigint |
ebool | 'bool' | boolean |
eaddress | 'address' | 校验和格式字符串 |
明文是在本地从 KMS 份额重建的——绝不以明文形式在网络上传输。
批量解密:减少签名与网络往返
两种批量变体可以避免逐个值签名和网络往返:
// 同一合约内的多个值: 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[]。从源码看,decryptValues(decryptValues.ts)内部会把同一合约的多个 handle 统一带上ownerAddress构造 pair 列表,再交给底层的decryptValuesFromPairs管线——因此批量与单值走的是同一条 KMS 解密路径,只是请求合并为一次;而decryptValue本质上就是只有一个 pair 的decryptValuesFromPairs(见 decryptValue.ts 第 47-51 行的转发)。
什么可以被解密:EncryptedValueLike
你传入的encryptedValue是一个bytes32handle。SDK 接受多种形态(EncryptedValueLike):
- 十六进制字符串,例如从合约 getter 读到的值;
- 32 字节的
Uint8Array; EncryptedValuehandle 对象。
典型做法是直接从合约调用中读取 handle。例如读取一个加密计数器:
const rawCount = await counter.getCount(); // uint256 getter 返回 bigint const countHex = '0x' + rawCount.toString(16).padStart(64, '0'); const decrypted = await client.decryptValue({ transportKeyPair, encryptedValue: countHex, contractAddress, signedPermit, });解密前的权限预检:canDecrypt*
如果 ACL(Access Control List,访问控制列表)不允许,解密调用会失败。为了提前检查——例如把"reveal"按钮置灰——可以使用canDecrypt*动作。它们返回布尔值加明细,权限不通过时绝不抛异常。这些是独立动作而非客户端方法,需要导入并把客户端作为第一个参数传入:
import { canDecryptValue } from '@fhevm/sdk/actions/decrypt'; const { allowed, details } = await canDecryptValue(client, { encryptedValue, contractAddress, signedPermit, // 或:userAddress: '0x…' }); allowed; // boolean details.contractAllowed; // 该合约是否被允许持有这个值? details.userAllowed; // 该用户是否被允许解密它?复数形式canDecryptValues与canDecryptValuesFromPairs(同样来自@fhevm/sdk/actions/decrypt)与批量解密方法一一对应。
从 canDecryptValue.ts 的源码注释可以看到其完整的预检语义:
- 它检查链上 ACL 对两件事的授权:
contractAddress对encryptedValue的访问,以及目标用户对encryptedValue的访问; - 传入
signedPermit时,额外检查 permit 的结构有效性、当前时间是否在有效窗口内、permit 是否限定在请求的contractAddress; - 同时传入
signedPermit与transportKeyPair时,还会校验 permit 是否绑定到对应的transportKeyPair.publicKey; - permit 限定于(用户、合约地址、传输公钥、有效时间窗口),但不限定于单个 encryptedValue——值级别的授权始终由链上 ACL 单独决定。
委托解密(Delegated decryption)
委托允许一个账户解密另一个账户拥有的值——例如一个服务代表链上已授权的用户执行解密。
用委托方的signer签署 permit,并在delegatorAddress中指明所有者。signLegacyDecryptionPermit与signUnifiedDecryptionPermit的用法与上文完全一致——委托在两者上是同一个delegatorAddress参数:
const signedPermit = await client.signLegacyDecryptionPermit({ transportKeyPair, contractAddresses: ['0xYourContract…'], startTimestamp: now, durationSeconds: 24 * 60 * 60, signerAddress: delegateAddress, // 委托方签署 signer: delegateSigner, delegatorAddress: ownerAddress, // 要解密谁的值 });生成的 permit 会报告isDelegated: true。用它解密的方式与上文完全相同。委托本身必须已经在链上 ACL 中被授予;签署 permit 只是授权这次请求。这一点在底层 SignedDecryptionPermit-p.ts 的signDecryptionPermit文档注释中也有对应说明:提供delegatorAddress时创建的是允许 signer 解密属于delegatorAddress账户的加密值的委托 permit,否则创建 signer 解密自己值的标准 permit。
公开解密
当合约将一个值标记为可公开解密时——例如密封拍卖的获胜出价,这是一次所有人都该看到的机密计算结果——用公开方法读取它。不需要传输密钥对,也不需要 permit。
// 单个值: const value = await client.decryptPublicValue({ encryptedValue }); value.value; // 解出的明文 value.type; // "uint32"、"bool"… // 批量: const values = await client.decryptPublicValues({ encryptedValues: [handleA, handleB], });两者返回的TypedValue(s) 与私有解密结果形状完全一致。
从源码看,公开解密的管线(publicDecrypt.ts)与私有解密有一个关键差异:它不要求用户提供 EIP-712 签名。SDK 可以透明地为用户读取当前 KMS signers 上下文并构建extraData(源码注释明确指出 "the publicDecrypt doesn't require for an EIP-712 signature from the user")。该管线还会执行一系列防御性校验:至少一个 handle、2048 bits 位数上限(assertKmsDecryptionBitLimit)、所有 handle 属于当前 host chainId、以及 ACL 权限检查,之后才调用 Relayer 获取 KMS 份额,并在本地验证签名后构造PublicDecryptionProof。
在链上验证公开解密结果
要向合约证明某个 handle 解密为某个特定明文值,使用decryptPublicValuesWithSignatures。它返回 KMS 签名以及验证合约所期望的确切参数:
const { clearValues, checkSignaturesArgs } = await client.decryptPublicValuesWithSignatures({ encryptedValues: [handle], }); clearValues; // 解出的明文 TypedValue[] checkSignaturesArgs.handlesList; // 所有 handle checkSignaturesArgs.abiEncodedCleartexts; // ABI 编码的明文值 checkSignaturesArgs.decryptionProof; // KMS 门限签名(quorum signatures)把checkSignaturesArgs传给合约的验证函数,合约就能确认 KMS 门限已对这些明文值作出证明。对应实现 decryptPublicValuesWithSignatures.ts 中checkSignaturesArgs的类型定义明确包含handlesList(非空HandleBytes32Hex数组)、abiEncodedCleartexts(BytesHex)与decryptionProof(BytesHex),三者被Object.freeze冻结后返回。GLOSSARY(GLOSSARY.md)将其中的decryptionProof定义为 KMS 公开解密证明:包含 KMS 签名、关联元数据以及链上验证所需的上下文。
持久化解密会话
传输密钥对和已签名的 permit 都可以序列化为普通对象——可用于在页面刷新间缓存解密会话,使用户不必每次访问都重新签名。
// 序列化(异步——两者都会先解析客户端的协议上下文): const kp = await client.serializeTransportKeyPair({ transportKeyPair }); const permit = await client.serializeSignedDecryptionPermit({ signedPermit }); // 持久化 `kp` 和 `permit`(例如存入 storage) // 稍后恢复: const transportKeyPair = await client.parseTransportKeyPair(kp); const signedPermit = await client.parseSignedDecryptionPermit({ serializedPermit: permit, transportKeyPair, });序列化实现中有两个值得注意的安全设计(TransportKeyPair-p.ts 与 SignedDecryptionPermit-p.ts):
- 序列化后的传输密钥对包含私钥——
serializeTransportKeyPair的 JSDoc 直接标注"The output contains sensitive key material — handle and store securely"; - permit 序列化时会把 EIP-712 domain 中的 bigint
chainId转为十进制字符串,保证产物能安全通过JSON.stringify()/JSON.parse()(如存入 localStorage);parseSignedDecryptionPermit再将其还原为 bigint。同时toJSON()被刻意不放在 permit 类上,防止JSON.stringify(permit)意外序列化敏感数据。
危险提示:序列化的传输密钥对包含私钥。请把它当作秘密对待:绝不写入日志、绝不嵌入 URL、绝不发送到服务器。只存放在你会存放会话密钥的地方。
相关文档
- Encryption——生成你读回来的加密值。
- Types——
TransportKeyPair、SignedDecryptionPermit、TypedValue的类型定义。 - Actions——以独立函数形式提供的相同操作(如
@fhevm/sdk/actions/decrypt与@fhevm/sdk/actions/base的完整导出清单)。 - Error handling——
AclUserDecryptionError、AclPublicDecryptionError等错误类型。 - Clients——四类客户端工厂的 WASM 加载与能力矩阵。
- GLOSSARY——公开解密、
DecryptionProof等术语的精确定义。
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考