本文深入探讨了使用Go语言编写的快速ZK-SNARK库Gnark,该库提供高层和低层API,用于设计零知识证明电路。文章通过实例展示了如何使用Gnark进行ZK证明的生成和验证,并分析了在使用低层API时发现的一些问题,如需要手动构建电路约束以及可能存在的安全隐患,最后总结了Gnark在ZKP应用开发中的优缺点,适合希望深入了解Gnark库的开发者阅读。
零知识证明 (ZKP) 是一种强大的密码学技术,允许双方交换信息,而无需泄露任何敏感数据。这种方法有潜力彻底改变我们在金融、医疗保健和政府等各个行业中处理隐私和安全的方式。然而,开发 ZKP 应用程序传统上是一项具有挑战性的任务,需要深入了解密码学、编程和数学。
幸运的是,随着技术的进步以及新的库和框架的开发,编写 ZKP 应用程序变得容易得多。如今,有几个库可以显着降低开发 ZKP 应用程序的复杂性,例如 LambdaWorks、Arkworks 和 Gnark。这些库为开发人员提供了一组工具和构建块,可以简化复杂密码协议的实现。
在本文中,我们将重点介绍 Gnark,它是可用于 ZKP 开发的最强大且用户友好的库之一。 Gnark 是一个开源库,为开发人员提供了一种高级编程语言和一组工具,用于构建高效且安全的 ZKP 应用程序。我们将探讨 gnark 的特性和优点,并展示它如何简化构建 ZKP 应用程序的过程。
Gnark 用 Go 编写,是一个快速的 ZK-SNARK 库,提供高级 API 和低级 API 来设计电路。该库是开源的,并根据 Apache 2.0 许可开发。
我们使用 Gnark 作为 Noir 的后端。 Noir 是一种用于创建和验证证明的领域特定语言。 Noir 编译为中间语言,该中间语言本身可以编译为算术电路或 rank-1 constraint system。这本身在设计过程中带来了一些挑战,但允许将编程语言与后端完全分离。这在理论上类似于 LLVM。
生成 ZK 证明并验证它的主要流程是:
Gnark 具有高级 API 和低级 API。 主要区别在于算术化。 在高级 API 中,作为用户,你是从 R1CS 或 SparseR1CS 构建中抽象出来的,而在低级 API 中,你需要手动构建它们(逐个约束)。
在以下各节中,我们将解释并展示高级和低级 API 的一些示例用法。 我们将首先展示 Gnark 的光明面,即高级 API。

Gnark 的高级 API 位于 frontend 包中,你可以在 repo 的根目录中找到它。
早些时候我们说过,主要区别在于算术化,但这是什么意思? 怎么会? 通过算术化,我们基本上是指构建将用于生成证明的电路。
在 frontend 包的情况下,构建电路意味着创建你的电路结构,其字段必须是标记为公共或秘密的电路变量(未标记的字段默认被假定为秘密变量)(又名电路输入)。 这些输入必须是 frontend.Variable 类型,并构成 witness。 witness 有一个只有证明者知道的秘密部分和一个证明者和验证器都知道的公共部分。
在你构建好电路结构后,你需要定义电路的行为。 你必须通过编写 Define 函数来做到这一点。 Define 声明电路逻辑。 然后,编译器生成一个约束列表,必须满足这些约束(有效的 witness)才能创建有效的 ZK-SNARK。 下面的示例中的电路证明了 RSA-250 挑战的因式分解。
type Circuit struct {
P frontend.Variable // p --> 秘密可见性(默认)
Q frontend.Variable `gnark:"q,secret"` // q --> 秘密可见性
RSA frontend.Variable `gnark:",public"` // rsa --> 公共可见性
}
func (circuit *Circuit) Define(api frontend.API) error {
// 确保我们不接受 RSA * 1 == RSA
api.AssertIsDifferent(circuit.P, 1)
api.AssertIsDifferent(circuit.Q, 1)
// 计算 P * Q 并将其存储在局部变量 res 中。
rsa := api.Mul(circuit.P, circuit.Q)
// 断言语句 P * Q == RSA 为真。
api.AssertIsEqual(circuit.RSA, rsa)
return nil
}

位于 repo 根目录的 constraint 模块中,我们可以找到编写 R1CS(对于 Groth16)或稀疏 R1CS(对于 Plonk)“手动”所需的几乎所有内容。 手动是指逐个约束地构建我们的电路约束。 我之前说过几乎所有内容,因为我们还需要来自 gnark-crypto 库的一些东西(提供椭圆曲线和基于配对的密码学以及对零知识证明系统特别感兴趣的各种算法)。
我们说这里的算术化是手动的,因为电路结构和约束都需要手动编写。
要添加电路输入,你需要使用方法 AddPublicVariable、AddSecretVariable 和 AddInternalVariable。 调用此方法将返回一个索引,该索引对应于 witness 向量中该变量的具体值。 你调用这些方法的顺序 matters,因为内部当前 witness 索引(在你正在构建的电路中)正在发生变化。
电路行为,在高级 API 中必须在 Define 函数中编写,而无需手动生成约束,而是使用方法 AddConstraint 逐个约束地定义。 可以通过逐项初始化 constraint.R1C(对于 Groth16)或 constraint.SparseR1C(对于 Plonk)来构建约束。 最后,可以使用 MakeTerm 方法创建项。
此后,后续步骤(证明和验证)与高级 API 中的相同。
下一个代码片段证明 x⋅y=zx⋅y=z,其中 x,zx,z 是公共变量,yy 是私有变量 (witness):
func Example() {
// [Y, Z]
publicVariables := []fr_bn254.Element{fr_bn254.NewElement(2), fr_bn254.NewElement(6)}
// [X]
secretVariables := []fr_bn254.Element{fr_bn254.NewElement(3)
/* R1CS 构建 */
// (X * Y) == Z
// X 是秘密的
// Y 是公开的
// Z 是公开的
r1cs := cs_bn254.NewR1CS(1)
// 变量
_ = r1cs.AddPublicVariable("1") // ONE_WIRE
Y := r1cs.AddPublicVariable("Y")
Z := r1cs.AddPublicVariable("Z")
X := r1cs.AddSecretVariable("X")
// 系数
COEFFICIENT_ONE := r1cs.FromInterface(1)
// 约束
// (1 * X) * (1 * Y) == (1 * Z)
constraint := constraint.R1C{
L: constraint.LinearExpression{r1cs.MakeTerm(&COEFFICIENT_ONE, X)}, // 1 * X
R: constraint.LinearExpression{r1cs.MakeTerm(&COEFFICIENT_ONE, Y)}, // 1 * Y
O: constraint.LinearExpression{r1cs.MakeTerm(&COEFFICIENT_ONE, Z)}, // 1 * Z
}
r1cs.AddConstraint(constraint)
constraints, r := r1cs.GetConstraints()
for _, r1c := range constraints {
fmt.Println(r1c.String(r))
}
/* 通用 SRS 生成 */
pk, vk, _ := groth16.Setup(r1cs)
/* 证明 */
rightWitness := buildWitnesses(r1cs, publicVariables, secretVariables)
p, _ := groth16.Prove(r1cs, pk, rightWitness)
/* 验证 */
publicWitness, _ := rightWitness.Public()
verifies := groth16.Verify(p, vk, publicWitness)
fmt.Println("使用正确的公共值验证:", verifies == nil)
wrongPublicVariables := []fr_bn254.Element{fr_bn254.NewElement(1), fr_bn254.NewElement(5)}
wrongWitness := buildWitnesses(r1cs, wrongPublicVariables, secretVariables)
wrongPublicWitness, _ := wrongWitness.Public()
verifies = groth16.Verify(p, vk, wrongPublicWitness)
fmt.Println("使用错误的公共值验证:", verifies == nil)
}
为了能够运行它,你需要 buildWitness 函数:
func buildWitnesses(r1cs *cs_bn254.R1CS, publicVariables fr_bn254.Vector, privateVariables fr_bn254.Vector) witness.Witness {
witnessValues := make(chan any)
go func() {
defer close(witnessValues)
for _, publicVariable := range publicVariables {
witnessValues <- publicVariable
}
for _, privateVariable := range privateVariables {
witnessValues <- privateVariable
}
}()
witness, err := witness.New(r1cs.CurveID().ScalarField())
if err != nil {
log.Fatal(err)
}
// -1,因为第一个变量是 ONE_WIRE。
witness.Fill(r1cs.GetNbPublicVariables()-1, r1cs.GetNbSecretVariables(), witnessValues)
return witness
}
使用低级 API 时,有一个小细节需要考虑。 也许你已经注意到了,但如果没有,请看一下上面示例中的这一行:
_ = r1cs.AddPublicVariable("1") // ONE_WIRE
你可能想知道如果我们不使用该函数返回的变量,为什么这是必要的。 好吧,我们喜欢编写代码,所以让我们删除该行以及 buildWitness 函数中的此补丁(对于此补丁,请删除该函数的 witness.Fill 行中的 -1)执行代码。
这样做时,你会收到此错误:
18:32:36 ERR error="无效的 witness 大小,得到 3,预期 2 = 1(公共)+ 1(秘密)" backend=groth16 nbConstraints=1
该错误表明我们预计有 2 个变量(1 个公共变量和 1 个私有变量),这是错误的。我们已经声明了 3 个变量(2 个公共变量和 1 个私有变量)。
发生这种情况的原因以及补丁有效的原因超出了本文的范围,但这是 Gnark 泄漏到 API 中的实现细节。你可以在此 issue 中阅读有关该内容的更多信息。
我们在算术化代码中发现了一个小错误。
让我们稍微修改一下我们之前 Groth16 的例子
func Example() {
// [Y, Z]
publicVariables := []fr_bn254.Element{fr_bn254.NewElement(2), fr_bn254.NewElement(5)}
// [X]
secretVariables := []fr_bn254.Element{fr_bn254.NewElement(5)}
/* R1CS 构建 */
// (X * Y) == Z + 5
// X 是秘密的
// Y 是公开的
// Z 是公开的
// 5 是常数
r1cs := cs_bn254.NewR1CS(1)
// 变量
_ = r1cs.AddPublicVariable("1") // ONE_WIRE
Y := r1cs.AddPublicVariable("Y")
Z := r1cs.AddPublicVariable("Z")
X := r1cs.AddSecretVariable("X")
// 常数
FIVE := r1cs.FromInterface(5)
CONST_FIVE_TERM := r1cs.MakeTerm(&FIVE, 0)
CONST_FIVE_TERM.MarkConstant()
// 系数
COEFFICIENT_ONE := r1cs.FromInterface(1)
// 约束
// (1 * X) * (1 * Y) == (1 * Z) + (5 * 1)
constraint := constraint.R1C{
L: constraint.LinearExpression{r1cs.MakeTerm(&COEFFICIENT_ONE, X)}, // 1 * X
R: constraint.LinearExpression{r1cs.MakeTerm(&COEFFICIENT_ONE, Y)}, // 1 * Y
O: constraint.LinearExpression{
r1cs.MakeTerm(&COEFFICIENT_ONE, Z)}, // 1 * Z 1
CONST_FIVE_TERM, // 5
}
r1cs.AddConstraint(constraint)
constraints, r := r1cs.GetConstraints()
for _, r1c := range constraints {
fmt.Println(r1c.String(r))
}
/* 通用 SRS 生成 */
pk, vk, _ := groth16.Setup(r1cs)
/* 证明 */
rightWitness := buildWitnesses(r1cs, publicVariables, secretVariables)
p, _ := groth16.Prove(r1cs, pk, rightWitness)
/* 验证 */
publicWitness, _ := rightWitness.Public()
verifies := groth16.Verify(p, vk, publicWitness)
fmt.Println("使用正确的公共值验证:", verifies == nil)
wrongPublicVariables := []fr_bn254.Element{fr_bn254.NewElement(1), fr_bn254.NewElement(5)}
wrongWitness := buildWitnesses(r1cs, wrongPublicVariables, secretVariables)
wrongPublicWitness, _ := wrongWitness.Public()
verifies = groth16.Verify(p, vk, wrongPublicWitness)
fmt.Println("使用错误的公共值验证:", verifies == nil)
}
乍一看,这似乎可以顺利进行,但尝试一下并运行它。 发现有什么问题了吗? 如果你尝试过,你的答案是肯定的,因为过了一段时间后,你会收到一条 signal: killed 消息。
没问题,让我们修复它。 只需删除以下行:
CONST_FIVE_TERM.MarkConstant()
区别只在于一行; 我们做的和上面一样,只是我们没有将常量项标记为常量。
如果你运行上面的修复程序,你会看到执行成功完成,每个人都很高兴。 但没那么快。 这意味着你,作为 Gnark 用户,可以绕过此问题并构建一个可用的电路。 但是,恶意用户仍然可以创建错误的电路来破坏执行。
通过这种漏洞,运行 Gnark prover 的服务器接受任意电路(Noir 和 Aleo 指令是允许发生此行为的语言的一个示例)可以通过 DDoS 攻击被关闭。 用户可以重复发送上面显示的错误电路以供执行,浪费周期并一遍又一遍地强制崩溃。
从我们的角度来看,Gnark 是开发 ZKP 应用程序的最佳工具之一,它有很多优点和缺点,具体取决于你想做什么。 通常,如果你想开发 ZKP 应用程序,高级 API 对你来说就足够了。 在我们的例子中,我们需要更深入地研究,因此我们发现了一些缺陷。
因此,如果你有兴趣了解有关如何使用 Gnark 开发 ZKP 应用程序的更多信息,请继续关注我们即将发布的博客文章。 我们将为你提供一个循序渐进的指南,并向你展示使用这个神奇的库构建强大而安全的 ZKP 应用程序是多么容易。
- 原文链接: blog.lambdaclass.com/how...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!