开源代码实现见:
L2IV Research团队近期尝试用ZKP来验证FHE,原因在于如下2大应用场景的出现:
FHE方案有多种,L2IV Research团队尤其关注Zama所使用TFHE。其实现的TFHE使用模 p = 2 64 p=2^{64} p=264——其可在现代CPU和其它硬件平台上高效计算。L2IV Research团队对验证FHE的兴趣源于其在fhEVM中的直接适用性。
但是,使用模 p = 2 64 p=2^{64} p=264,对ZKP来说挑战不小,因仅有有限的ZKP系统可高效兼容该模:
L2IV Research团队选择RISC Zero的原因有3:
本文概述了FHE和RISC Zero,详细介绍了将现有Rust代码用于RISC Zero的过程,介绍了RISC Zero中一种新的数据加载优化技巧,并演示了使用Ghidra对RISC-V代码进行分解和分析,以确定进一步的优化机会。
2023年12月14日更新:我们注意到“include_bytes”可能无法正确对齐数据,并可能导致对齐错误。因此,选择使用include_bytes_aligned crate中的include_bytes_aligned
。
fully homomorphic encryption (FHE) 为加密算法,表示为 E E E,用于做数据加密。如已知明文 a a a,经FHE加密后获得密文 E ( a ) E(a) E(a):
a → E ( a ) a \rightarrow E(a) a→E(a)
全同态性,意味着可基于密文做加法、减法和乘法运算:
当明文为二进制位(0和1)时,可使用FHE来表示所有二进制逻辑门。包括XOR和AND这样的基础门,因其构成了FHE中任意二进制逻辑运算的基础:
由于其可表示所有二进制门,FHE可执行bounded size内的任意计算。在区块链应用中,FHE因在去中心化金融(DeFi)应用中实现隐私而引起了极大的兴趣。例如,在隐私增强的去中心化交易所(DEX)中,FHE可以秘密地为自动做市商(AMM)处理计算。
FHE的大部分计算开销归因于管理和减轻“噪声(noise)”。所有现有的FHE构建都依赖于learning-with error(LWE)假设或其变种——构成了这些密码学系统的基础。对于计算的每一步,输出——如 E ( a + b − a × b ) E(a+b-a\times b) E(a+b−a×b)——将比输入 E ( a ) E(a) E(a)和 E ( b ) E(b) E(b)具有更多的噪声,并且该输出可能成为后续步骤的输入。随着计算的进行,密文积累的噪声量越来越大。如下图所示,一旦密文中的噪声达到某个阈值,就会使密文不可解密。【下图摘自2021年Zama团队Marc Joye论文《Guide to Fully Homomorphic Encryption over the [Discretized] Torus》。】
为促进FHE中的无限计算,必须找到一种在噪声变得过大之前清除噪声的方法。这种技术被称为“自举(bootstrapping)”——由Craig Gentry于2009年通过其关于FHE的开创性论文首次引入。bootstrapping包括使用FHE secret key的加密版本,通常被称为“bootstrapping key”,来解密并刷新有噪声的密文,这会产生包含相同数据但噪声较小的新密文。
可以想象,对于密文来说,FHE计算是一项非常累人的工作——就像马拉松一样,密文需要休息以避免“精疲力竭(burn out)”。如下图所示,将FHE bootstrapping看成是某人需要休息以避免在马拉松中精疲力尽。
在不同的全同态加密(FHE)算法中,TFHE引起了人们的极大关注,因为TFHE中的自举是有效的,并且TFHE非常适合于评估加密数据上的布尔电路。Zama、Fhenix、Inco都在使用TFHE。
因此,验证FHE的主要挑战在于准确地验证自举过程。在TFHE中,自举包括使用自举密钥根据被自举的密文“盲目地旋转(blindly rotate)”多项式,随后从这个旋转后的多项式中提取刷新的密文。
虽然这最初看起来像是对高级密码学的一次尝试,但值得注意的是,这个过程主要围绕着操作多项式和矩阵,如下图所示,其摘自2021年Zama团队Marc Joye论文《Guide to Fully Homomorphic Encryption over the [Discretized] Torus》。对于那些热衷于深入研究的人,我们强烈推荐Marc Joye关于TFHE的这本入门书,对于那些只对线性代数有基本了解的人来说,这本书很容易上手。
RISC Zero是专门为RISC-V体系结构设计的通用零知识证明系统。换句话说,任何可通过riscv32im(用于整数乘法和除法的,具有“M”扩展的RISC-V 32位)编译成ELF(executable and linkable format)程序的程序都与RISC Zero兼容。在VM执行时,RISC Zero生成该执行的零知识证明,称为“receipt”。具体如下图所示。
人们常问的一个问题是:RISC Zero为什么选择RISC-V而不是其它指令集?其主要原因有2方面:
LB, LH, LW, LBU, LHU, ADDI, SLLI, SLTI, SLTIU, XORI, SRLI, SRAI, ORI, ANDI, AUIPC, SB, SH, AW, ADD, SUB, SLL, SLT, SLTU, XOR, SRL, SRA, OR, AND, MUL, MULH, MULSU, MULU, DIV, DIVU, REM, REMU, LUI, BEQ, BNE, BLT, BGE, BGEU, JALR, JAL, ECALL, EBREAK
相比于现代Intel x86的1131条指令,以及现代ARM的数百条指令,其要简单得多。同时,RISC-V也与其他极简主义指令集没有太大区别——MIPS(MIPS公司后来转型为RISC-V)、WASM和早期几代ARM,如ARMv4T。换句话说,通过自下而上的方法,RISC Zero:
人们会问的另一个问题是,尽管RISC-V是一个有利的选择,为什么以虚拟机方式启动?难道不能在现有的ZK-specific DSL中编写“约束系统”吗,比如ZoKrates、Cairo、Noir和Circom?
主要原因有二:
接下来将探讨如何利用RISC Zero以零知识验证Zama的FHE计算。看起来,所需要的只是重用Zama团队的一些代码,然后添加几行代码。
首先,需展示如何来验证某FHE密文bootstrapping的主要步骤:
#![no_main]
risc0_zkvm::guest::entry!(main);
// load the toy FHE Rust library from Louis Tremblay Thibault (Zama)
use ttfhe::{N,
ggsw::{cmux, GgswCiphertext},
glwe::GlweCiphertext,
lwe::LweCiphertext
};
// load the bootstrapping key and the ciphertext to be bootstrapped
static BSK_BYTES: &[u8] = include_bytes_aligned!(8, "../../../bsk");
static C_BYTES: &[u8] = include_bytes_aligned!(8, "../../../c");
pub fn main() {
// a zero-copy trick to load the key and the ciphertext into RISC Zero
let bsk = unsafe {
std::mem::transmute::<&u8, &[GgswCiphertext; N]>(&BSK_BYTES[0])
};
let c = unsafe {
std::mem::transmute::<&u8, &LweCiphertext>(&C_BYTES[0])
};
// initialize the polynomial to be blindly rotated
let mut c_prime = GlweCiphertext::trivial_encrypt_lut_poly();
c_prime.rotate_trivial((2 * N as u64) - c.body);
// perform the blind rotation
for i in 0..N {
c_prime = cmux(&bsk[i], &c_prime, &c_prime.rotate(c.mask[i]));
}
eprintln!("test res: {}", c_prime.body.coefs[0]);
}
以上代码中,除加载依赖或常量数据等trivial操作代码行之外,实际的关键代码仅5行——包括多项式的初始化及其blindly rotating:
let mut c_prime = GlweCiphertext::trivial_encrypt_lut_poly();
c_prime.rotate_trivial((2 * N as u64) - c.body);
for i in 0..N {
c_prime = cmux(&bsk[i], &c_prime, &c_prime.rotate(c.mask[i]));
}
执行FHE steps的关键函数和算法,均源自Zama团队Louis Tremblay Thibault所开发https://github.com/tremblaythibaultl/ttfhe/ Rust库,具体关键函数和算法有:
trivial_encrypt_lut_poly
rotate_trivial
cmux
使用RISC Zero,可为该RISC-V程序的执行生成proof。RISC Zero的如下代码会执行该RISC-V程序(为ELF格式的可执行文件),并生成认证该执行的proof(称为“receipt”):
let env = ExecutorEnv::builder().build().unwrap();
let prover = default_prover();
let receipt = prover.prove_elf(env, METHOD_NAME_ELF).unwrap();
receipt.verify(METHOD_NAME_ID).unwrap();
可将receipt发送给第三方,第三方可验证该RISC-V程序的执行情况,而无需访问其详细工作。对于需要更紧凑证明格式的情况,RISC Zero还能够生成succinct proof,在更简洁的同时保留其可验证性。
以Zama团队Louis Tremblay Thibault所开发https://github.com/tremblaythibaultl/ttfhe/ toy FHE Rust库为例,来演示如何使用RISC Zero来验证FHE。选择这个库的原因有二:
该toy FHE Rust库是极简主义的——它只有6个文件,包含800行代码——但它完全支持将使用的三种不同类型的FHE密文:
这就足以启动使用RISC Zero的开发,因为主要需求是一个高效的Rust实现,其可无缝编译到RISC-V。Louis Tremblay Thibault已经在VFHE库中开发了这些概念的初步版本(https://github.com/tremblaythibaultl/vfhe),作为基本出发点:
#![no_main]
use risc0_zkvm::guest::env;
use ttfhe::{ggsw::BootstrappingKey, glwe::GlweCiphertext, lwe::LweCiphertext};
risc0_zkvm::guest::entry!(main);
pub fn main() {
// bincode can serialize `bsk` into an blob that weighs 39.9MB on disk.
// This `env::read()` call doesn't seem to stop - memory is allocated until the process goes OOM.
let (c, bsk): (LweCiphertext, BootstrappingKey) = env::read();
let lut = GlweCiphertext::trivial_encrypt_lut_poly();
// `blind_rotate` is a quite heavy computation that takes ~2s to perform on a M2 MBP.
// Maybe this is why the process is running OOM?
let blind_rotated_lut = lut.blind_rotate(c, &bsk);
let res_ct = blind_rotated_lut.sample_extract();
env::commit(&res_ct);
}
不过,在上述代码的注释中,明确指出了其存在的2个主要问题:
env::read
channel,这是在proof生成期间将数据从外部输入RISC-V机器的标准方法。然而,正如Louis所指出的,这种方法并不是最优的,主要是由于其显著的内存需求和仅用于数据加载所需的大量VM CPU周期,导致了内存不足(OOM)问题。RISC Zero的Parker Thompson承认,这可能是问题的根源:“通常,向client读取大块数据的成本相当高。”include_bytes_aligned!
宏,指示编译器将数据集成到RISC-V可执行文件中。随后,可从byte反序列化这些数据,例如,使用bincode::deserialize
。代码如下所示:static BSK_BYTES: &[u8] = include_bytes_aligned!(8, ("../../../bsk");
let bsk: BootstrappingKey = bincode::deserialize(BSK_BYTES);
然而,重大挑战,源自,RISC-V程序为整个64MB自举密钥分配内存和复制数据所需的大量cycles。基准测试表明,仅证明密钥的正确加载就需要至少2个小时。L2IV Research团队深入研究了克服RISC-V程序中数据加载挑战的方法,强调了显著减少RISC-V CPU周期的方法。
核心思想是避免在RISC-V中复制数据。这一点至关重要,因为在RISC-V中复制64MB的数据集将需要超过5000万条指令——一条指令读取数据,一条指令写入数据,以及一条指令更新指针。所有这些指令在某种程度上都是不必要的,因为Rust编译器已经将数据作为RISC-V程序的一部分包含在内,因此数据已经可用。
由于其固有的内存安全设计,在Rust中实现这一点具有挑战性。Rust中的标准实践涉及通过一个严格的过程初始化数据结构:在stack或heap中分配数据结构,将数据结构的内存归零(通过在整个内存中学以致用地填充零),然后一个接一个地复制数据。可以看出,Rust在计算周期上的花费甚至更大,因为将内存归零至少需要另外3400万条指令。
L2IV Research团队的解决方案采用了某些低级Rust原语,能够“绕过”Rust LLVM编译器施加的限制,从而更有效地编程RISC-V。
在与Greater Heat合作进行Aleo mining的过程中,学到了一项有价值的技术,涉及std::mem::transmite
。这是一个特殊的Rust函数,可以将一种类型的bits重新解释为另一种类型。特别是,它可以用来修改指针的类型。
在本应用中,可直接在RISC-V文件中显式地嵌入(或者更准确地说,硬编码)自举密钥(BSK_bytes)和要自举的密文(C_bytes。为了避免复制数据,可直接操作指针的类型,如下所示:
let bsk = unsafe {
std::mem::transmute::<&u8, &[GgswCiphertext; N]>(&BSK_BYTES[0])
};
let c = unsafe {
std::mem::transmute::<&u8, &LweCiphertext>(&C_BYTES[0])
};
如前一段代码所示,获得了指向硬编码数据的ELF段指针,最初是一个字节指针(&u8
)。然后,将其转换为指向自举密钥的指针(&[GgswCiphertext; N]
)或指向LWE密文的指针(&LweCiphertext
)。此外,有必要将此代码封装在“不安全”括号中,因为Rust将此低级函数归类为不安全,并要求通过不安全明确承认其潜在风险。这种不安全的使用本身并不意味着危险;相反,这意味着需要专门的专业知识来处理这种低级操作。
对于熟悉C/C++的人来说,这个过程可以比作typecasting。在C/C++中,等效代码如下所示:
/* C */
BootstrappingKey *bsk = (BootstrappingKey*) &BSK_BYTES[0];
LweCiphertext *c = (LweCiphertext*) &C_BYTES[0];
/* C++ */
BootstrappingKey *bsk = reinterpret_cast<BootstrappingKey*>(&BSK_BYTES[0]);
LweCiphertext *c = reinterpret_cast<LweCiphertext*>(&C_BYTES[0]);
实验结果表明,使用这种方法实际上消除了数据加载通常需要的cycles数。后续分析将通过使用RISC-V反编译器进一步验证这种零拷贝操作。
L2IV Research团队已经解决了来自Zama的Louis在使用RISC Zero进行FHE自举验证时遇到的最初挑战。在本系列即将发表的文章中,将深入探讨性能改进及其细微差别这一主题。
本节关注,使用RISC-V反编译器在零知识上下文中检查RISC Zero所验证的程序。目标有两个:
为此,使用https://github.com/NationalSecurityAgency/ghidra——由US National Security Agency (NSA)开发的全面、免费使用的逆向工程框架,其支持RISC-V。
上图展示了对RISC Zero所证明的RISC-V程序的Ghidra CodeBrowser。
Ghidra同时支持:
先专注于自动生成的反编译代码,如下所示:
/* method_name::main */
void method_name::main(void)
{
uint *puVar1;
uint *puVar2;
int iVar3;
undefined auStack_c018 [8192];
undefined auStack_a018 [8192];
undefined *local_8018;
code *local_8014;
int *local_4018 [2];
undefined1 *local_4010;
undefined4 local_400c;
undefined **local_4008;
undefined4 local_4004;
gp = &__global_pointer$;
ttfhe::glwe::GlweCiphertext::trivial_encrypt_lut_poly(auStack_c018);
ttfhe::glwe::GlweCiphertext::rotate_trivial((int)auStack_c018,0x600);
puVar2 = &anon.874983810a662adbf4687c54e184621b.1.llvm.4718791565163837729;
puVar1 = (uint *)&anon.874983810a662adbf4687c54e184621b.0.llvm.4718791565163837729;
iVar3 = 0x400;
do {
ttfhe::glwe::GlweCiphertext::rotate(local_4018,(int)auStack_c018,*puVar2);
ttfhe::ggsw::cmux(&local_8018,puVar1,(int)auStack_c018,(int)local_4018);
memcpy(auStack_c018,&local_8018,0x4000);
puVar2 = puVar2 + 2;
iVar3 = iVar3 + -1;
puVar1 = puVar1 + 0x4000;
} while (iVar3 != 0);
local_8018 = auStack_a018;
local_8014 = u64>::fmt;
local_4010 = anon.874983810a662adbf4687c54e184621b.4.llvm.4718791565163837729;
local_400c = 2;
local_4018[0] = (int *)0x0;
local_4008 = &local_8018;
local_4004 = 1;
std::io::stdio::_eprint(local_4018);
return;
}
初步可确认,其数据加载流程确实实现了zero-copy efficiency。该代码片段使用std::mem::transmute
来将数据加载编译进16-byte sequence of RISC-V machine codes:
37 c5 20 04 93 04 c5 4c 37 c5 20 00 13 04 c5 4c
反编译展示了4条汇编指令,负责将指针值存储到s1和s0寄存器中。本质上,该代码分配0x420c4cc
值给s1寄存器,分配0x020c4cc
值给s0寄存器:
00200844 37 c5 20 04 lui a0,0x420c
00200848 93 04 c5 4c addi s1,a0,0x4cc
0020084c 37 c5 20 00 lui a0,0x20c
00200850 13 04 c5 4c addi s0,a0,0x4cc
可进一步将该汇编代码反编译为C类似格式,以有更清晰的理解,如下所示:
uint *puVar1;
uint *puVar2;
puVar2 = &anon.874983810a662adbf4687c54e184621b.1.llvm.4718791565163837729;
puVar1 = (uint *)&anon.874983810a662adbf4687c54e184621b.0.llvm.4718791565163837729;
在该反编译代码中,首个label anon.874983810a662adbf4687c54e184621b.1.llvm.4718791565163837729
明确指出了密文字节的位置,表示为C_BYTES
。利用Ghidra,可直接观察该代码中的这些密文字节:
上图,展示了0x420c4cc
位置的ELF可执行文件的数据,即s1寄存器的初始值,用于待自举的密文。
C_BYTE包含在名为"c"的文件中。通过使用Hex Fiend,一种用于十六进制编辑的工具,可检查该文件的内容。如下所述,检查确认了数据的一致性。
static C_BYTES: &[u8] = include_bytes!("../../../c");
如上十六进制编辑器中所示,"c"文件中存储了待自举的密文。
类似地,对第二个label——anon.874983810a662adbf4687c54e184621b.0.llvm.4718791565163837729
,可定位对应自举密钥的字节,称为BSK_BYTES
。
上图,展示了0x020c4cc
位置的ELF可执行文件的数据,即s0寄存器的初始值,用于自举密钥。
此外,可通过将该数据与其源文件“bsk”进行交叉检查来验证该数据,确保其与上述信息一致。
上图,在十六进制编辑器中,展示了“bsk”文件中存储了该自举密钥。
接下来,将展示如何在程序中使用该自举密钥和密文。Rust源代码中,通过明确调用cmux
函数,在for循环中同时集成该自举密钥和密文:
// perform the blind rotation
for i in 0..N {
c_prime = cmux(&bsk[i], &c_prime, &c_prime.rotate(c.mask[i]));
}
然后,借助Ghidra来定位并检查相应的反编译代码:
puVar2 = &anon.874983810a662adbf4687c54e184621b.1.llvm.4718791565163837729;
puVar1 = (uint *)&anon.874983810a662adbf4687c54e184621b.0.llvm.4718791565163837729;
iVar3 = 0x400;
do {
ttfhe::glwe::GlweCiphertext::rotate(local_4018,(int)auStack_c018,*puVar2);
ttfhe::ggsw::cmux(&local_8018,puVar1,(int)auStack_c018,(int)local_4018);
memcpy(auStack_c018,&local_8018,0x4000);
puVar2 = puVar2 + 2;
iVar3 = iVar3 + -1;
puVar1 = puVar1 + 0x4000;
} while (iVar3 != 0);
该反编译代码中包含不同组件:
cmux
Function Invocation:cmux
关键函数,以bsk[i] (puVar1)、原始c_prime、和已旋转的c_prime 为输入,输出的结果到local_8018中。cmux
消除此拷贝可以提高效率。当iVar3值为0时,以上循环结束,这些steps共同表示在RISC-V程序中处理FHE自举的过程。
使用Ghidra的进一步分析能够仔细审查该计划的其他部分,从而深入了解潜在的优化机会。这个过程有助于评估Rust RISC-V编译器是否按预期生成RISC-V指令。
如,检查cmux
函数的反编译代码。为了提供上下文,将首先考虑原始的Rust代码,如下所示:
/// Ciphertext multiplexer. If `ctb` is an encryption of `1`, return `ct1`. Else, return `ct2`.
pub fn cmux(ctb: &GgswCiphertext, ct1: &GlweCiphertext, ct2: &GlweCiphertext) -> GlweCiphertext {
let mut res = ct2.sub(ct1);
res = ctb.external_product(&res);
res = res.add(ct1);
res
}
其反编译代码表明,对“sub”和“add”的函数调用在编译过程中被有效地内联了。这种内联在代码中产生可见的循环,这些循环负责模拟64位整数运算。此外,该代码还使用了对memset
和memcpy
的几个调用。值得注意的是,memset
的一些实例用于归零内存,这可能并不总是必要的。这一观察结果开辟了潜在的优化途径,特别是在消除不必要的memset
调用方面。
上图展示了cmux
函数的反编译RISC-V指令代码。
[1] L2IV Research团队2023年11月16日博客 Tech Deep Dive: Verifying FHE in RISC Zero, Part I:From Hidden to Proven: The ZK Path of FHE Validation