有限域上的椭圆曲线长什么样?
我们很容易想象平滑的椭圆曲线,但是有限域上的椭圆曲线看起来究竟是什么样的呢?
下面是 的图像:

因为我们只允许输入整数(更准确地说,是有限域元素),所以我们不会得到一个平滑的图像。
在解方程时,并非每个 值都能得出一个整数的 值,因此在这些 值处不会有对应的点。你可以从上面的图像中看到这些空白。
生成该图像的代码将在稍后提供。
素数会极大地改变图像
以下是分别在模 11、23、31 和 41 下绘制的 的图像。模数越大,包含的点就越多,图像看起来也越复杂。


我们在上一篇文章中已经确定,通过“连线并翻转”操作,椭圆曲线上的点构成了一个群(group)。当我们在有限域上进行此操作时,它仍然是一个群,但它变成了一个循环群(cyclic group),这在我们的应用中非常有用。不幸的是,解释它为什么是循环群需要非常复杂的数学知识,所以你现在只需接受这一点即可。但这并不应该太令人惊讶。我们有有限数量的点,因此通过执行 来生成每个点至少看起来是合理的。
在密码学应用中, 需要非常大。在实践中,它通常超过 200 位。我们将在后面的章节中再次探讨这一点。
背景
域元素 (Field element)
在本文中,我们会经常提到“域元素”(field element),希望通过之前的教程(尤其是关于有限域的教程),你已经对这个术语感到熟悉了。如果还不熟悉,你可以把它理解为一个在模运算范围内的正整数。
也就是说,如果我们进行模 11 的加法运算,那么该集合中的有限域元素就是 。即使那是我们在 Python 示例中将使用的数据类型,称其为“整数”(integers)也是不准确的。在对素数进行模加法时,不可能存在负的域元素(尽管整数可以是负数)。有限域中的“负数”仅仅是另一个数的加法逆元(additive inverse),即两个数相加结果为零的数。例如,在模 11 的有限域中,4 可以被视为“-7”,因为 等于 ,这就类似于常规数字中 7 + (-7) 等于 0。
在有理数域上,乘法的单位元是 1,一个数的逆元仅仅是分子和分母的翻转。例如,500/303 是 303/500 的逆元,因为如果将它们相乘,你会得到 1。
在有限域中,一个元素的逆元是与之相乘能得到有限域元素 1 的数字。例如,在模 23 下,6 是 4 的逆元,因为在模 23 的情况下将它们相乘,你会得到 1。当域的阶(order)为素数时,除了零之外的每个数都有逆元。
循环群 (Cyclic Groups)
循环群是一个群,其特点是每个元素都可以通过从一个生成元(generator element)开始,并反复应用该群的二元运算符来计算得出。
一个非常简单的例子是加法下的模 11 整数。如果你的生成元是 1,并且你不断将生成元与其自身相加,你就可以生成该群中从 0 到 10 的所有元素。
说椭圆曲线上的点构成一个循环群(在椭圆曲线加法下),意味着我们可以将有限域中的每个数字表示为一个椭圆曲线点,并将它们加在一起,就像在有限域中对常规整数进行加法一样。
也就是说,
同态于
其中 G 是椭圆曲线循环群的生成元。
这仅适用于点数为素数的有限域上的椭圆曲线,而这正是我们在实践中使用的曲线类型。这也是我们稍后将再次探讨的内容。
BN128 公式
以太坊预编译(precompiles)用于验证 ZK 证明的 BN128 曲线规定如下:
这里的 是域模数(field modulus)。
不应将 field_modulus 与曲线的阶(curve order,即曲线上的点的数量)混淆。
对于 bn128 曲线,其阶如下:
from py_ecc.bn128 import curve_order
# 21888242871839275222246405745257275088548364400416034343698204186575808495617
print(curve_order)
这个域模数非常大,使得用它进行实验很不方便。在下一节中,我们将使用相同的公式但采用较小的模数,来建立对有限域中椭圆曲线点的直观理解。
生成椭圆曲线循环群
为了求解上述方程并确定哪些 点在该曲线上,我们需要计算 。
模平方根 (Modular square roots)
我们使用 Tonelli Shanks 算法来计算模平方根。如果你感到好奇,可以去阅读一下该算法的工作原理,但现在你可以把它当作一个黑盒,它能在某个模数下计算域元素的数学平方根,或者告诉你平方根不存在。
例如,模 11 下 5 的平方根是 4 ,但模 11 下 6 的平方根不存在。(鼓励读者通过暴力破解法来验证这一点)。
平方根通常有两个解,一个正数和一个负数。尽管我们在有限域中没有带负号的数字,但我们仍然有“负数”的概念,即拥有逆元的意义。
你可以在网上找到实现上述算法的代码,但为了避免在本教程中放入大量代码块,我们将转而安装一个 Python 库。
python3 -m pip install libnum
安装完 libnum 之后,我们可以运行以下代码来演示它的用法。
from libnum import has_sqrtmod_prime_power, sqrtmod_prime_power
# the functions take arguments# has_sqrtmod_prime_power(n, field_mod, k), where n**k,
# but we aren't interested in powers in modular fields, so we set k = 1
# check if sqrt(8) mod 11 exists
print(has_sqrtmod_prime_power(8, 11, 1))
# False
# check if sqrt(5) mod 11 exists
print(has_sqrtmod_prime_power(5, 11, 1))
# True
# compute sqrt(5) mod 11
print(list(sqrtmod_prime_power(5, 11, 1)))
# [4, 7]
assert (4 ** 2) % 11 == 5
assert (7 ** 2) % 11 == 5
# we expect 4 and 7 to be additive inverses of each other, because in "regular" math, the two solutions to a square root are sqrt and -sqrt
assert (4 + 7) % 11 == 0
现在我们知道如何计算模平方根了,我们可以遍历 的值,并通过公式 来计算 。求解 只是对两边取模平方根(如果存在的话),并保存结果 对,以便我们稍后绘制它们。
让我们绘制一个简单的椭圆曲线图像
import libnum
import matplotlib.pyplot as plt
def generate_points(mod):
xs = []
ys = []
def y_squared(x):
return (x**3 + 3) % mod
for x in range(0, mod):
if libnum.has_sqrtmod_prime_power(y_squared(x), mod, 1):
square_roots = libnum.sqrtmod_prime_power(y_squared(x), mod, 1)
# we might have two solutions
for sr in square_roots:
ys.append(sr)
xs.append(x)
return xs, ys
xs, ys = generate_points(11)
fig, (ax1) = plt.subplots(1, 1);
fig.suptitle('y^2 = x^3 + 3 (mod p)');
fig.set_size_inches(6, 6);
ax1.set_xticks(range(0,11));
ax1.set_yticks(range(0,11));
plt.grid()
plt.scatter(xs, ys)
plt.plot();
绘制的结果如下所示:

一些观察结果:
- 没有任何 x 或 y 值会大于或等于我们使用的模数
- 就像实数值的图像一样,模运算的图像“看起来也是对称的”
椭圆曲线点加法
更有趣的是,我们用于计算椭圆曲线的“连线并翻转”操作依然有效!
但是考虑到我们在有限域上进行操作,这并不令人惊讶。我们在实数域上的公式使用的是加法和乘法的正常域运算。尽管我们使用平方根来判断一个点是否在曲线上,且平方根并不是一个有效的域运算符,但我们在计算点的加法和翻倍时并不使用平方根。
读者可以通过从上面的图像中选取两个点来验证这一点,然后将它们代入下面的代码中进行点加法,并观察它们是否总是落在另一个点上(或者如果两个点互为逆元,则落在无穷远点上)。这些公式取自维基百科关于椭圆曲线点乘法的页面。
def double(x, y, a, p):
lambd = (((3 * x**2 + a) % p ) * pow(2 * y, -1, p)) % p
newx = (lambd**2 - 2 * x) % p
newy = (-lambd * newx + lambd * x - y) % p
return (newx, newy)
def add_points(xq, yq, xp, yp, p, a=0):
if xq == yq == None:
return xp, yp
if xp == yp == None:
return xq, yq
assert (xq**3 + 3) % p == (yq ** 2) % p, "q not on curve"
assert (xp**3 + 3) % p == (yp ** 2) % p, "p not on curve"
if xq == xp and yq == yp:
return double(xq, yq, a, p)
elif xq == xp:
return None, None
lambd = ((yq - yp) * pow((xq - xp), -1, p) ) % p
xr = (lambd**2 - xp - xq) % p
yr = (lambd*(xp - xr) - yp) % p
return xr, yr
以下是有限域中“连线并翻转”的一些可视化效果:

循环群中的每个椭圆曲线点都有一个“编号”
根据定义,循环群可以通过不断将生成元与其自身相加来生成。
让我们以 为例,生成元点为 。
使用上面的 Python 函数,我们可以从点 开始并生成该群中的每个点:
# for our purposes, (4, 10) is the generator point G
next_x, next_y = 4, 10
print(0, 4, 10)
points = [(next_x, next_y)]
for i in range(1, 13):
# repeatedly add G to the next point to generate all the elements
next_x, next_y = add_points(next_x, next_y, 4, 10, 11)
print(i, next_x, next_y)
points.append((next_x, next_y))
输出结果为
0 4 10
1 7 7
2 1 9
3 0 6
4 8 8
5 2 0
6 8 3
7 0 5
8 1 2
9 7 4
10 4 1
11 None None
12 4 10 # note that this is the same point as the first one
请注意 。就像模加法一样,当我们“溢出”时,循环会重新开始。
在这里,None 表示无穷远点,它确实是群的一部分。将无穷远点与生成元相加会返回生成元,因为这就是单位元应有的表现。
我们可以根据将生成元与自身相加达到该点所需的次数,为每个点分配一个“编号”。
我们可以使用以下代码来绘制曲线并在其旁边分配一个编号
xs11, ys11 = generate_points(11)
fig, (ax1) = plt.subplots(1, 1);
fig.suptitle('y^2 = x^3 + 3 (mod 11)');
fig.set_size_inches(13, 6);
ax1.set_title("modulo 11")
ax1.scatter(xs11, ys11, marker='o');
ax1.set_xticks(range(0,11));
ax1.set_yticks(range(0,11));
ax1.grid()
for i in range(0, 11):
plt.annotate(str(i+1), (points[i][0] + 0.1, points[i][1]), color="red");
红色文本可以看作是从单位元开始,我们将生成元加到它上面的次数。

点的逆元仍然是垂直对称的
这里有一个有趣的观察结果:请注意,具有相同 x 值的点相加的编号之和为 12,对应于单位元 。如果我们将点 (即我们图像中的点 11)与 相加,我们将得到无穷远点,也就是群中的第 12 个元素。
阶数不等于模数
在这个例子中,群的阶数是 12(我们群中椭圆曲线点的总数),尽管椭圆曲线的公式是模 11。这需要强调几遍:你千万不要认为椭圆曲线中的模数就是群的阶。不过,你可以利用 Hasse 定理 (Hasse’s Theorem) 从域模数本身来估计曲线阶数的范围。
如果点的数量是素数,那么点的加法就表现得像有限域一样
在上面的图像中,有 12 个点(包括 0)。模 12 的加法不能构成一个有限域,因为 12 不是素数。
然而,如果我们谨慎地为曲线挑选参数,我们就可以创建出一条椭圆曲线,其中点与有限域中的元素相对应。也就是说,曲线的阶等于该有限域的阶。
例如, 创建了一条总共有 31 个点的曲线,如下面的图像所示:

当曲线的阶与有限域的阶匹配时,你在有限域中进行的每一次操作,在椭圆曲线中都有一个同态的等价操作。
为了从有限域转换到椭圆曲线,我们(任意)选择一个点作为生成元,然后将有限域中的元素与生成元相乘。
乘法本质上是重复的加法
其实并不存在所谓的椭圆曲线点乘法。当我们说“标量乘法”(scalar multiplication)时,我们实际上指的是重复的加法。你不能将两个椭圆曲线点相乘(嗯,在某种程度上你可以通过双线性配对 (bilinear pairings) 来实现,但这属于我们以后将涉及的内容)。
当我们使用 Python 库执行 multiply(G1, x) 时,这实际上等同于将 G1 + G1 + … + G1 执行 x 次。在底层,我们并没有实际自加那么多次,而是使用点翻倍(point doubling)等巧妙的快捷方法,在对数时间内完成操作。
例如,如果我们想要计算 135G,我们实际上会通过点翻倍高效地计算以下值并将它们缓存下来:
G, 2G, 4G, 8G, 16G, 32G, 64G, 128G
……然后将它们加总:128G + 4G + 2G + G = 135G。
当我们说 5G + 6G = 11G 时,本质上我们只是将 G 与其自身相加 11 次。使用如上所示的快捷方法,我们可以通过对数级别的计算量得出 11G,但归根结底,它就是重复的加法。
Python bn128 库
EVM 实现 pyEVM 用于椭圆曲线预编译的库是 py_ecc,我们将严重依赖这个库。下面的代码展示了生成元点的长相,并展示了一些加法和标量乘法。
以下是 G1 点的样子:
from py_ecc.bn128 import G1, multiply, add, eq, neg
print(G1)
# (1, 2)
print(add(G1, G1))
# (1368015179489954701390400359078579693043519447331113978918064868415326638035, 9918110051302171585080402603319702774565515993150576347155970296011118125764)
print(multiply(G1, 2))
#(1368015179489954701390400359078579693043519447331113978918064868415326638035, 9918110051302171585080402603319702774565515993150576347155970296011118125764)
# 10G + 11G = 21G
assert eq(add(multiply(G1, 10), multiply(G1, 11)), multiply(G1, 21))
尽管这些数字很大且难以阅读,但我们可以看到,将一个点与自身相加,其结果与将该点“乘以” 2 的值相同。上面的两个点显然是同一个点。该元组仍然是一个 对,只不过处在一个非常大的定义域中。
上面打印的数字之所以如此巨大是有原因的。我们不希望攻击者能够获取一个椭圆曲线点,并反向计算出生成它的域元素。如果我们的循环群阶数太小,攻击者就可以直接通过暴力破解来解决。
以下是前 1000 个点的图像:

这是生成上述图像的代码:
import matplotlib.pyplot as plt
from py_ecc.bn128 import G1, multiply, neg
import math
import numpy as np
xs = []
ys = []
for i in range(1,1000):
xs.append(i)
ys.append(int(multiply(G1, i)[1]))
xs.append(i)
ys.append(int(neg(multiply(G1, i))[1]))
plt.scatter(xs, ys, marker='.')
这看起来可能有些吓人,但与我们在上一节中所做的唯一不同之处在于,这里使用了一个大得多的模数和一个不同的点作为生成元。
库中的加法
py_ecc 库为我们进行点加法提供了便利,其语法一目了然:
from py_ecc.bn128 import G1, multiply, add, eq
# 5 = 2 + 3
assert eq(multiply(G1, 5), add(multiply(G1, 2), multiply(G1, 3)));
有限域中的加法与椭圆曲线点之间的加法(当它们的阶相等时)是同态的。由于离散对数问题,另一方可以在不知道究竟是哪些域元素生成这些点的情况下,将椭圆曲线点加在一起。
讲到这里,希望读者已经在理论和实践两个层面对椭圆曲线点的加法有了良好的直观理解,因为现代零知识证明算法严重依赖于这一点。
关于模加法和椭圆曲线加法同态性的实现细节
我们需要在这里仔细区分以下术语:
域模数(field modulus)是我们绘制曲线时使用的模数。曲线阶数(curve order)是曲线上点的数量。
如果你从点 开始并加上曲线阶数 ,你将再次得到 。如果你加上域模数,你会得到一个不同的点。
from py_ecc.bn128 import curve_order, field_modulus, G1, multiply, eq
x = 5 # chosen randomly
# This passes
assert eq(multiply(G1, x), multiply(G1, x + curve_order))
# This fails
assert eq(multiply(G1, x), multiply(G1, x + field_modulus))
这意味着 (x + y) mod curve_order == xG + yG。
x = 2 ** 300 + 21
y = 3 ** 50 + 11
# (x + y) == xG + yG
assert eq(multiply(G1, (x + y)), add(multiply(G1, x), multiply(G1, y)))
即使 x + y 运算显然会超出曲线阶数产生“溢出”,这也没有关系。就像在有限域中一样,这是我们预期的行为。椭圆曲线乘法隐式地执行了与乘法前取模相同的操作。
事实上,如果我们只关心正数,甚至不需要进行取模,以下等式也成立:
x = 2 ** 300 + 21
y = 3 ** 50 + 11
assert eq(multiply(G1, (x + y) % curve_order), add(multiply(G1, x), multiply(G1, y)))
然而,如果我们使用错误的数字(非曲线阶数的某个数字)进行有限域数学模运算,在“溢出”时等式就会被破坏
x = 2 ** 300 + 21
y = 3 ** 50 + 11 # these values are large enough to overflow:
assert eq(multiply(G1, (x + y) % (curve_order - 1)), add(multiply(G1, x), multiply(G1, y))), "this breaks"
编码有理数
当我们取模时,我们就能够对除法的概念进行编码。
例如,我们不能使用常规整数执行以下操作。
# this throws an exception
eq(add(multiply(G1, 5 / 2), multiply(G1, 1 / 2), multiply(G1, 3)
然而在有限域中,1/2 可以被有意义地计算为 2 的乘法逆元。因此,5 / 2 可以编码为 。
以下是我们如何在 Python 中实现:
five_over_two = (5 * pow(2, -1, curve_order)) % curve_order
one_half = pow(2, -1, curve_order)
# Essentially 5/2 = 2.5# 2.5 + 0.5 = 3
# but we are doing this in a finite field
assert eq(add(multiply(G1, five_over_two), multiply(G1, one_half)), multiply(G1, 3))
结合律 (Associativity)
我们知道群具有结合律,因此我们预期以下等式在通常情况下是成立的:
x = 5
y = 10
z = 15
lhs = add(add(multiply(G1, x), multiply(G1, y)), multiply(G1, z))
rhs = add(multiply(G1, x), add(multiply(G1, y), multiply(G1, z)))
assert eq(lhs, rhs)
鼓励读者自己尝试 x、y 和 z 的不同取值。
每个元素都有逆元
py_ecc 库为我们提供了 neg 函数,它会通过沿 x 轴翻转(在有限域内)来提供给定元素的逆元。该库将“无穷远点”编码为 Python 的 None。
from py_ecc.bn128 import G1, multiply, neg, is_inf, Z1
# pick a field element
x = 12345678
# generate the point
p = multiply(G1, x)
# invert
p_inv = neg(p)
# every element added to its inverse produces the identity element assert is_inf(add(p, p_inv))
# Z1 is just None, which is the point at infinity
assert Z1 is None
# special case: the inverse of the identity is itself
assert eq(neg(Z1), Z1)
正如实数域上的椭圆曲线一样,椭圆曲线点的逆元具有相同的 x 值,但 y 值是原值的逆元。
from py_ecc.bn128 import G1, neg, multiply
field_modulus = 21888242871839275222246405745257275088696311157297823662689037894645226208583
for i in range(1, 4):
point = multiply(G1, i)
print(point)
print(neg(point))
print('----')
# x values are the same
assert int(point[0]) == int(neg(point)[0])
# y values are inverses of each other, we are adding y values
# not ec points
assert int(point[1]) + int(neg(point)[1]) == field_modulus
每个元素都可以由生成元生成
当我们要处理超过 个点时,是无法通过暴力破解来验证的。然而,考虑到 eq(multiply(G1, x), multiply(G1, x + order)) 总是为真这一事实。这意味着我们最多能够生成 order 个点,然后就会循环回起点。
那么 optimized_bn128 呢?
检查该库时,你会看到一个名为 optimized_bn128 的实现。如果你对其执行时间进行基准测试,你会发现这个版本运行得快得多,它也是 pyEvm 所使用的实现。但出于教学目的,最好还是使用未优化的版本,因为它的点结构更直观(常见的 x, y 元组)。优化版本将椭圆曲线点构造为三元组(3-tuples),这更难以解读。
from py_ecc.optimized_bn128 import G1, multiply, neg, is_inf, Z1
print(G1)
# (1, 2, 1)
使用椭圆曲线实现基础零知识证明
考虑这个相当简单的例子:
声明:“我知道两个值 和 ,使得 ”
证明:我将 x 乘以 G1,y 乘以 G1,并将结果作为 A 和 B 提供给你。
验证者:你将 15 乘以 G1,并检查 A + B == 15G1。
以下是它的 Python 实现:
from py_ecc.bn128 import G1, multiply, add
# Prover
secret_x = 5
secret_y = 10
x = multiply(G1, 5)
y = multiply(G1, 10)
proof = (x, y, 15)
# verifier
if multiply(G1, proof[2]) == add(proof[0], proof[1]):
print("statement is true")
else:
print("statement is false")
尽管验证者不知道 x 和 y 是什么,但他们可以验证 x 和 y 在椭圆曲线空间中相加等于 15,因此作为有限域元素,secret_x 和 secret_y 相加等于 15。
读者可以将其作为练习去尝试更复杂的操作,比如证明知道某个线性方程组的解。
作为一个(非常重要的)提示,将一个数字乘以一个常数与重复加法是一回事。重复加法与椭圆曲线的标量乘法也是一回事。因此,如果 x 是一个椭圆曲线点,我们可以将其乘以标量 9,即 multiply(x, 9)。这与我们之前所说的“不能将椭圆曲线点相乘”相一致——我们实际上是将椭圆曲线点乘以一个标量,而不是乘以另一个点。
你能证明你知道满足 的 吗?你能将其推广到更多的变量吗?
另一个提示是:你(证明者)和验证者需要事先就公式达成一致,因为验证者需要运行与你声称知道其解的原始公式相同的“结构”。
安全假设 (Security assumptions)
为了使上述方案安全,我们假设如果我们公布一个诸如 multiply(G1, x) 的点,攻击者无法从生成的 值推断出 的原始值是多少。这就是离散对数假设。这就是为什么我们在计算公式时使用的素数需要很大的原因,这样攻击者就无法通过暴力破解来猜测。
目前存在一些更复杂的算法,比如能比暴力破解表现更好的大步小步 (baby step giant step) 算法。
注意:BN128 这个名称来源于它具有 128 位安全性的假设。虽然该椭圆曲线是在一个 254 位的有限域中计算的,但由于存在比简单的暴力破解更优秀的算法来计算离散对数,因此人们认为它仅具备 128 位的安全性。
真正的零知识 (True zero knowledge)
我们还应该指出,我们的 A + B = 15G 示例并不是真正的零知识。如果攻击者猜测 a 和 b,他们可以通过将生成的椭圆曲线点与我们的进行比较来验证他们的猜测。
这个问题的解决方案将在后续章节中讨论。
将有限域上的椭圆曲线当作魔法黑盒处理
就像你不需要知道哈希函数底层是如何工作的就能使用它一样,你也不需要知道将椭圆曲线点相加或将其与标量相乘的具体实现细节。
然而,你确实需要了解它们所遵循的规则。冒着像复读机一样啰嗦的风险,我们再重申一遍,它们遵循循环群的规则:
- 椭圆曲线点加法是封闭的(closed):它会产生另一个椭圆曲线点
- 椭圆曲线点加法满足结合律(associative)
- 存在一个单位元(identity element)
- 每个元素都有一个逆元(inverse),相加时会产生单位元
只要你明白这一点,你就可以随心所欲地进行加法、乘法和求逆运算,而不会执行任何无效操作。其中每一项操作在 py_ecc 库中都有对应的函数。
这是本节课需要记住的最重要的事情:
有限域上的椭圆曲线同态加密了有限域中的加法。
天书般的数学:我们怎么知道曲线的阶?
读者可能会想,如果不去计算该公式的所有有效解,我们怎么能确定 bn128 曲线的阶呢。有效点的数量之多超出了任何计算机的枚举能力,那么我们是如何得出曲线阶数的?
这正是我们试图避免触及的那类数学问题,因为它非常高深。事实证明,使用 Schoof 算法 (Schoof’s Algorithm) 可以在多项式时间内计算出点的数量。你不需要理解这个算法是如何工作的,只要知道该算法存在就足够了。从实现的角度来看,我们究竟是如何得出曲线阶数并不重要,我们只关心设计者是否计算正确。
RareSkills 这里的教材经过精心设计,以避开这些数学雷区。
通过 RareSkills 了解更多
这就是为什么我们的零知识证明课程如此强调抽象代数基础的原因。理解椭圆曲线的实现细节简直是噩梦般地困难。但是,虽然起初会觉得不习惯,理解循环群的行为对于大多数程序员来说是完全可理解的。一旦我们理解了这一点,尽管该操作很难可视化,但椭圆曲线点加法的一般行为就会变得直观起来。
原载于 2023 年 9 月 19 日