追梦人物 博主
一直走在追梦的路上。

0x02:Secp256k1

2026-04-013 阅读0 评论

如果你使用过加密货币钱包,在创建钱包时,它通常会给你生成 12 个单词,称为“助记词”,它会强制让你用笔抄下来(不允许你截图),这就是你钱包的“口令”,持有此口令者即可随意操作对应的钱包,因此绝对不能泄露。钱包创建后会显示一个地址,其他人可以向这个地址转账。

这里面实际涉及到这样一个转换过程:助记词 -> 私钥 -> 公钥 -> 地址。

其中助记词 -> 私钥的转换过程并不包含在以太坊的核心实现中,而是由一个外部规范定义,主要由钱包应用实现。私钥 -> 公钥 -> 地址的转换过程属于以太坊的核心实现,私钥 -> 公钥是本篇关注的内容,公钥 -> 地址的转换将在下一篇详细介绍。

可以看到,私钥 -> 公钥这个链条必须是单向转换的,因为公钥会被分享出去,如果公钥可以反过来推出私钥,那就毫无安全可言了。提到单向转换,可能最容易想到的就是哈希函数。但是私钥和公钥在以太坊中的一个重要功能是私钥签名,公钥验证,哈希函数不具备这样的性质。需要选择一个数学性质更为良好的工具来做这个单向转换,比特币选择了 Secp256k1,以太坊则直接沿用了它前辈的选择。

本篇我们来探索私钥 -> 公钥转换涉及的 Secp256k1 相关概念,彻底理解这一过程。

群和域

为了更好的理解 Secp256k1,我们需要先来回顾一些抽象代数的基础概念。虽然这门课程很多专业都要到研究生阶段才会开设,但这里我们只需要了解基础的概念就行了,即使以前没有接触过,用小学学过的整数加减乘除类比就能理解。

群(Group)是抽象代数中最基础的代数结构之一,由非空集合和一个二元运算构成,满足四条公理,具体定义如下:

设非空集合为 \(G\)\(+\) 是定义在 \(G\) 上的二元运算,若 \((G,+)\) 满足以下四条群公理,则称 \((G,+)\) 为一个

  1. 封闭性:对任意的 \(a,b\in G\)\(a+b\in G\)
  2. 结合律:对任意的 \(a,b,c\in G\)\((a+b)+c = a+(b+c)\)
  3. 存在单位元:存在 \(e\in G\),使得对任意的 \(a\in G\)\(e+a = a+e = a\),该元素 \(e\) 称为群 \(G\) 的单位元
  4. 存在逆元:对任意的 \(a\in G\),存在 \(a^{-1}\in G\),使得 \(a+a^{-1} = a^{-1}+a = e\),元素 \(a^{-1}\) 称为元素 \(a\) 的逆元

例如整数和加法便构成一个群,称为整数加群,其中 0 是单位元,\(a\) 的逆元为其相反数 \(-a\)

特殊的,如果群还满足交换律(任意 \(a,b \in G\)\(a + b=b + a\)),则称为交换群(阿贝尔群,Abelian Group),显然整数加群是一个交换群。

由于结合律,\(a+a+...+a\) 结果无歧义,因此可将这种同一元素多次相加的形式简记为乘法形式 \(k{a}\),且规定若 \(k=0\)\(ka\) 的结果为单位元。

若群中存在元素 \(g \in G\),使得 \(G\) 中任意元素均可表示为 \(kg\)\(k\) 为整数),则称 \((G,+)\) 为循环群,元素 \(g\) 称为 \(G\) 的生成元(generator),记为 \(G=⟨g⟩\)。整数加群是一个循环群,其生成元是 1。

域(Field)是比群更复杂的代数结构,由非空集合和两个二元运算(分别记作加法和乘法)构成,其中加法构成交换群,非零元的乘法也构成交换群,且乘法对加法满足分配律,具体定义如下:

设非空集合为 \(F\),在 \(F\) 上定义两个二元运算:\(+\)(加法)和 \(\cdot\)(乘法),若 \((F, + , \cdot)\) 满足以下三组公理,则称 \(F\) 为一个

  1. 加法交换群\((F,+)\) 是交换群
  2. 非零元乘法交换群:设 \(F^* = F \setminus \{e\}\)\(F\) 中去掉加法单位元 \(e\)),则 \((F^*, \cdot)\) 是交换群
  3. 乘法对加法的分配律:对任意 \(a,b,c \in F\),有\(a \cdot (b+c) = a \cdot b + a \cdot c\)\((b+c) \cdot a = b \cdot a + c \cdot a\)(左右分配律)。

注意这里加法和乘法只是对两种运算的抽象称呼,加法和乘法是两种独立运算,不要将乘法和上一节中介绍的多个相同群元素相加的乘法简记形式(\(a+a+...+a = ka\))混淆。

若域中元素个数 \(q\) 有限,则称为有限域(伽罗瓦域,Galois Field),记为 \(GF(q)\)\(F_q\)\(q\) 为域的阶。

和本篇内容息息相关的一个域叫做 \(p\) 阶素数域 \(F_p\)。集合 \(F_p = \{0, 1, 2, ..., p-1\}\),其中 \(p\) 为素数。域上的加法和乘法运算都是模 \(p\) 的:\(a + b \equiv (a + b) \pmod p\)\(a \times b \equiv (a \times b) \pmod p\)。可以证明以上集合和运算构成一个域(套入域的三组公理可以验证,除了乘法逆元不那么显然以外,其他性质均继承自整数的加法和乘法,关于乘法逆元的存在性可问下 AI,这里不再占用过多篇幅进行说明)。

椭圆曲线

定义

Secp256k1 的椭圆曲线定义为:

$\(y^2 \equiv x^3 + 7 \pmod p\)$
其中 \(\equiv\) 是同余符号,可以理解为 \(y^2\)\(x^3 + 7\)\(p\) 的余数相同。椭圆曲线上的有效点是所有满足方程 \(y^2 \equiv x^3 + 7 \pmod p\) 的坐标对 \((x, y)\),其中 \(x, y \in F_p\)\(F_p\) 为上一节提到的 \(p\) 阶素数域。

数学家们发现的椭圆曲线的一些性质:

  1. 关于 x 轴对称
  2. 非 x 轴对称的两点之连线(可为同一个点,此时退化为切线),必与曲线上某点相交

以下是一个椭圆曲线的可视化示例:

elliptic_curve.png

给定点 \((x, y)\),很容易验证其是否在椭圆曲线上。👇下面是 Python 写的验算程序:

# secp256k1 曲线参数
p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
a = 0
b = 7

# 生成点 G 的坐标
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8


def is_on_curve(x, y, p, a, b):
    """验证点 (x, y) 是否满足椭圆曲线方程 y² ≡ x³ + ax + b (mod p)"""
    left = (y * y) % p
    right = (x * x * x + a * x + b) % p
    return left == right


# 验证生成点 G
print(f"生成点 G ({hex(Gx)}, {hex(Gy)}) 在曲线上: {is_on_curve(Gx, Gy, p, a, b)}")

x = 0x6E145CCEF1033DEA239875DD00DFB4FEE6E3348B84985C92F103444683BAE07B
y = 0x83B5C38E5E2B0C8529D7FA3F64D46DAA1ECE2D9AC14CAB9477D042C84C32CCD0
print(f"验证点 ({hex(x)}, {hex(y)}) 在曲线上: {is_on_curve(x, y, p, a, b)}")

加法运算

给定两个不同的点 \(P = (x_1, y_1)\)\(Q = (x_2, y_2)\),且 \(P \neq -Q\),它们的和 \(R = P + Q = (x_3, y_3)\) 定义如下:

几何规则:通过 \(P\)\(Q\) 画一条直线,这条直线与椭圆曲线相交于第三个点,取该点关于 x 轴的对称点,即为 \(R\)

elliptic_curve_add_p1_p2.png

给定两个相同的点 \(P = (x_1, y_1)\),它们的和 \(R = P + P = 2P = (x_3, y_3)\) 定义如下:

几何规则:在 \(P\) 点处作椭圆曲线的切线,这条切线与椭圆曲线相交于第二个点,取该点关于 x 轴的对称点,即为 \(2P\)

elliptic_curve_add_p1_p1.png
如果两个点关于 x 轴对称,从图上看没有交点,但是我们特殊规定其与曲线交于无穷远点,记作 \(O\)

elliptic_curve_add_vertically.png

另特殊规定,对于任意点 \(P\),其和无穷远点相加都有:

\[P + O = O + P = P\]

点的逆元

\(P = (x, y)\) 关于 x 轴的对称点 \(-P\) 是其逆元。在椭圆曲线上,逆元的定义非常简单:

\[-P = (x, -y \pmod p) = (x, p - y)\]

点与它的逆元相加,结果是无穷远点:

\[P + (-P) = O\]

群结构

根据前面的分析:

  1. 椭圆曲线(含无穷远点)上两个点相加,结果仍在曲线上,满足封闭性
  2. 无穷远点充当了单位元的功能,即存在单位元
  3. \(P\) 关于 x 轴对称的点 \(-P\) 是其逆元,即存在逆元
  4. 加法满足结合律(较为复杂,此处略过)和交换律

因此椭圆曲线上的点(包括无穷远点)构成一个加法交换群。进一步地还可以证明,这是一个循环群。

曲线参数

以下是椭圆曲线 secp256k1 的一些参数值,包括素数 \(p\)、阶 \(N\) 和生成点 \(G\) 的坐标。

素数 \(p\)(定义域的阶):

\(p = 2^{256} - 2^{32} - 2^9 - 2^8 - 2^7 - 2^6 - 2^4 - 1\)

十进制: 115792089237316195423570985008687907853269984665640564039457584007908834671663

十六进制:0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F

\(N\)(椭圆曲线所有点含无穷远点的总数):
十进制:

115792089237316195423570985008687907852837564279074904382605163141518161494337

十六进制:0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

生成点 \(G\)(基点,Base Point)的坐标:

Gx (十六进制):0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798

Gx (十进制): 55066263022277343669578718895168534326250603453777594175500187360389116729240

Gy (十六进制):0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8

Gy (十进制): 32670510020758816978083085130507043184471273380659243275938904335757337482424

生成点 G 是椭圆曲线的基点,可以生成曲线上的所有其它点。

私钥

私钥是一个在特定范围内的随机整数。具体来说,私钥 \(d\) 满足:

\[1 \leq d < N\]

其中 \(N\) 是 secp256k1 椭圆曲线的阶,即椭圆曲线所有点(含无穷远点)的总数。

可见私钥并无任何特殊性,它仅仅只是一个取值范围为 \([1, N)\) 的整数。但是私钥的选择必须极其讲究,不能选择容易被人猜到或者瞎撞就能撞到的值。目前被广泛采用的一种标准是由 BIP39 定义的助记词方案,你在加密钱包中看到的 12 个单词助记词就是这个标准规定的的产物,它在保证私钥熵源的不可猜测性(高安全性)的同时,兼顾了人类可记忆性、易备份性与输入容错性。

公钥

从私钥 \(d\) 到公钥 \(Q\) 的转换非常简单:

\[ Q = d \times G \]

其中,\(G\) 是 secp256k1 的生成点。乘法是在此前关于群的章节中定义的乘法,\(d \times G\) 即点 \(G\) 相加 \(d\) 次。

前面已经说过,secp256k1 椭圆曲线的点构成循环群,所以公钥也是椭圆曲线上的一个点 \((x, y)\),其中 \(x, y \in F_p\)。在实际应用中,公钥有两种主要的序列化格式:未压缩格式(uncompressed)和压缩格式(compressed)。

未压缩格式占 65 字节,格式为 0x04 前缀 + 32 字节 x 坐标 + 32 字节 y 坐标。例如生成点 G 的未压缩格式为:

0479BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8

压缩格式占 33 字节,格式为前缀 0x020x03 + 32 字节 x 坐标。前缀 0x02 表示 y 坐标是偶数,0x03 表示 y 坐标是奇数。压缩格式的原理是:已知椭圆曲线方程 \(y^2 \equiv x^3 + 7 \pmod p\),如果知道 x 坐标,可以解出 \(y \equiv \pm\sqrt{x^3 + 7} \pmod p\)。对于每个 x,最多有两个对应的 y 值:\(y\)\(-y\)。这两个值一个是偶数,一个是奇数(因为 \(p\) 是奇数),因此只需要一个前缀位来区分。

压缩格式将公钥大小从 65 字节减少到 33 字节,节省了近 50% 的存储空间和传输带宽。以太坊的私钥导入格式(如 Keystore 文件)中通常存储未压缩公钥,但在交易签名和地址生成时使用压缩公钥。

go-ethereum 中的实现

本节阅读提示

本篇重点主要还是理解 secp256k1 椭圆曲线的数学性质和私钥到公钥的转换机制,具体的代码实现细节偏密码学,了解有哪些实用的函数和接口就好,对理解以太坊本身没有太大帮助,所以看看就好。

go-ethereum 以太坊客户端中包含了完整的 secp256k1 椭圆曲线实现。这些实现位于 crypto/secp256k1 包中,提供了私钥生成、公钥生成、点运算等核心功能。

实现概述

go-ethereum 提供了两种实现方式:
- Go 纯实现:完全用 Go 语言编写的实现,位于 curve.goscalar_mult_nocgo.go
- C 优化实现:调用 libsecp256k1 库的优化实现,位于 scalar_mult_cgo.go

外部统一使用 Go 接口,内部会根据编译配置自动选择最优实现。

曲线参数定义

crypto/secp256k1/curve.go 文件中定义了 secp256k1 的所有参数:

// BitCurve 表示一条 Koblitz 曲线(a=0 的椭圆曲线)
type BitCurve struct {
    P       *big.Int // 有限域的素数
    N       *big.Int // 曲线的阶(order)
    B       *big.Int // 椭圆曲线方程的常数项(y² = x³ + b)
    Gx, Gy  *big.Int // 生成点 G 的 (x, y) 坐标
    BitSize int      // 位的长度(256位)
}

// secp256k1 曲线参数(在包初始化时设置)
var theCurve = new(BitCurve)

func init() {
    // 素数 p:定义有限域 F_p
    theCurve.P, _ = new(big.Int).SetString(
        "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F", 0)

    // 阶 N:曲线上有效点的总数
    theCurve.N, _ = new(big.Int).SetString(
        "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 0)

    // 常数 b = 7(曲线方程 y² = x³ + 7)
    theCurve.B, _ = new(big.Int).SetString(
        "0x0000000000000000000000000000000000000000000000000000000000000007", 0)

    // 生成点 G 的 x 坐标
    theCurve.Gx, _ = new(big.Int).SetString(
        "0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798", 0)

    // 生成点 G 的 y 坐标
    theCurve.Gy, _ = new(big.Int).SetString(
        "0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8", 0)

    // 位长度:256位
    theCurve.BitSize = 256
}

// S256 返回 secp256k1 椭圆曲线实例
func S256() *BitCurve {
    return theCurve
}

这些参数与我们在前面的章节中讨论的完全一致。S256() 函数是获取 secp256k1 曲线实例的入口,所有椭圆曲线操作都通过这个实例进行。

私钥生成

私钥生成在 crypto/crypto.go 中实现,使用 Go 标准库的密码学安全随机数生成器:

import (
    "crypto/ecdsa"
    "crypto/rand"
    "github.com/ethereum/go-ethereum/crypto/secp256k1"
)

// GenerateKey 生成一个新的 secp256k1 私钥
func GenerateKey() (*ecdsa.PrivateKey, error) {
    // 使用 S256() 获取 secp256k1 曲线
    // rand.Reader 提供密码学安全的随机数
    return ecdsa.GenerateKey(S256(), rand.Reader)
}

公钥生成

从私钥生成公钥通过标量乘法实现。crypto/secp256k1/curve.go 中的 ScalarBaseMult 函数:

// ScalarBaseMult 计算 k × G,其中 G 是生成点,k 是标量(私钥)
// 参数 k 是大端序(big-endian)字节数组
// 返回值是公钥的 (x, y) 坐标
func (bitCurve *BitCurve) ScalarBaseMult(k []byte) (*big.Int, *big.Int) {
    // 调用通用标量乘法函数,以生成点 G 为基点
    return bitCurve.ScalarMult(bitCurve.Gx, bitCurve.Gy, k)
}

ScalarBaseMultScalarMult 的特例,专门用于基点 G 的标量乘法。这个函数会被编译器优化,因为基点是固定的,可以使用预计算表加速。

通用的标量乘法函数 ScalarMult 实现了完整的 \(k \times P\) 运算(P 可以是任意点)。go-ethereum 提供了两个版本:Go 版本(scalar_mult_nocgo.go)和 C 优化版本(scalar_mult_cgo.go)。C 版本使用了 libsecp256k1 库的高性能实现。

点运算

点运算包括点加法(Add)和点加倍(Double),是椭圆曲线运算的基础。

点加法

// Add 计算 (x1, y1) + (x2, y2)
// 返回和点的 (x, y) 坐标
func (bitCurve *BitCurve) Add(x1, y1, x2, y2 *big.Int) (*big.Int, *big.Int) {
    // 处理无穷远点(单位元)
    // 如果一个点是无穷远点,返回另一个点
    if x1.Sign() == 0 && y1.Sign() == 0 {
        return x2, y2
    }
    if x2.Sign() == 0 && y2.Sign() == 0 {
        return x1, y1
    }

    z := new(big.Int).SetInt64(1) // 仿射坐标的 z = 1

    // 如果两个点相同,调用点加倍
    if x1.Cmp(x2) == 0 && y1.Cmp(y2) == 0 {
        return bitCurve.affineFromJacobian(bitCurve.doubleJacobian(x1, y1, z))
    }

    // 否则调用 Jacobian 坐标系下的点加法
    return bitCurve.affineFromJacobian(bitCurve.addJacobian(x1, y1, z, x2, y2, z))
}

点加法的实现中,有一个重要的优化:Jacobian 坐标系

Jacobian 坐标系优化

在仿射坐标(affine coordinates)中,椭圆曲线的点用 \((x, y)\) 表示。在 Jacobian 坐标系中,点用 \((x, y, z)\) 表示,其中:

\[x_{\text{affine}} = \frac{x}{z^2} \pmod p\]
\[y_{\text{affine}} = \frac{y}{z^3} \pmod p\]

Jacobian 坐标系的优势在于:点加法和点加倍运算不需要进行昂贵的模逆运算(modular inversion)。模逆运算的计算复杂度远高于模乘法,是椭圆曲线运算的主要性能瓶颈。

在 Jacobian 坐标系下完成一系列运算后,通过 affineFromJacobian 函数转换回仿射坐标:

// affineFromJacobian 将 Jacobian 坐标转换为仿射坐标
// 输入:(x, y, z),输出:(xOut, yOut)
func (bitCurve *BitCurve) affineFromJacobian(x, y, z *big.Int) (xOut, yOut *big.Int) {
    // 如果 z = 0,表示无穷远点
    if z.Sign() == 0 {
        return new(big.Int), new(big.Int)
    }

    // 计算 z^(-1) mod p
    zinv := new(big.Int).ModInverse(z, bitCurve.P)

    // 计算 z^(-2)
    zinvsq := new(big.Int).Mul(zinv, zinv)

    // xOut = x * z^(-2) mod p
    xOut = new(big.Int).Mul(x, zinvsq)
    xOut.Mod(xOut, bitCurve.P)

    // yOut = y * z^(-3) mod p
    zinvsq.Mul(zinvsq, zinv)
    yOut = new(big.Int).Mul(y, zinvsq)
    yOut.Mod(yOut, bitCurve.P)

    return
}

点加倍

// Double 计算 2 × (x, y) = (x, y) + (x, y)
func (bitCurve *BitCurve) Double(x, y *big.Int) (*big.Int, *big.Int) {
    z := new(big.Int).SetInt64(1) // 仿射坐标的 z = 1
    // 在 Jacobian 坐标系下执行点加倍,然后转换回仿射坐标
    return bitCurve.affineFromJacobian(bitCurve.doubleJacobian(x, y, z))
}

doubleJacobian 函数实现了 Jacobian 坐标系下的点加倍运算,使用了优化的算法减少模运算次数。

公钥序列化

go-ethereum 提供了公钥的序列化和反序列化函数,支持未压缩格式:

// Marshal 将点 (x, y) 序列化为未压缩格式
// 格式:0x04 + x(32字节) + y(32字节),共65字节
func (bitCurve *BitCurve) Marshal(x, y *big.Int) []byte {
    byteLen := (bitCurve.BitSize + 7) >> 3 // 计算字节长度:(256+7)/8 = 32
    ret := make([]byte, 1+2*byteLen)
    ret[0] = 4 // 未压缩格式的前缀

    // 将 x 和 y 写入字节数组(大端序)
    math.ReadBits(x, ret[1:1+byteLen])
    math.ReadBits(y, ret[1+byteLen:])
    return ret
}

// Unmarshal 将序列化的数据解析为点 (x, y)
func (bitCurve *BitCurve) Unmarshal(data []byte) (x, y *big.Int) {
    byteLen := (bitCurve.BitSize + 7) >> 3

    // 验证数据长度和前缀
    if len(data) != 1+2*byteLen {
        return
    }
    if data[0] != 4 { // 必须是未压缩格式的前缀
        return
    }

    // 解析 x 和 y
    x = new(big.Int).SetBytes(data[1 : 1+byteLen])
    y = new(big.Int).SetBytes(data[1+byteLen:])
    return
}

这些函数与之前讨论的公钥格式完全对应。

总结

  • secp256k1 椭圆曲线定义在 \(p\) 阶素数域上,方程为 \(y^2 \equiv x^3 + 7 \pmod p\),曲线上的点构成加法群,且是循环群,阶为 \(N\),选择群中的一个点作为生成点 \(G\)
  • 私钥 \(d\)\((0,N]\) 的一个整数,私钥的选择要尽可能随机,不能被爆破
  • 公钥 \(Q = d \times G\)

-- EOF --

0 评论
登录后回复

暂无评论,来抢沙发吧!