一、需求:只有我能花我的钱
你要向一屋子陌生人证明一件事:这笔钱是我的,我同意花掉它。
难点在于,你不能靠"出示密码"——只要你把密码说出来,屋里每个人都能拿它去花你的钱。你需要一种说出来也不会泄密的证明方式。
上一讲解决了"数据完整性"。现在处理另一个问题:授权。
链上有一笔资金标记为"属于 Alice"。当一笔花费它的交易出现时,全网节点需要验证:这确实是 Alice 本人发出的。
节点们互不相识,也没有任何预共享的秘密。所以:
❌ 密码: 验证方需要知道密码 ⟹ 全网都知道 ⟹ 谁都能花
❌ 对称密钥: 同上,验证需要密钥,而密钥能生成
✅ 非对称密钥:⭐ 验证只需要公钥,签名只有私钥能生成
这就是公钥密码学要提供的能力:
⭐ 存在一对数
(d, Q),用d能生成签名,用Q能验证签名,而从Q推不出d。
这一讲讲 (d, Q) 是怎么来的,下一讲讲签名怎么做。
二、单向性从哪来:离散对数
我们需要一个"正着算很快、反着算极难"的运算。
先看一个更简单的例子
在模 p 的整数里(p 是大素数),取一个基数 g:
正向:给定 g 和 x,算 y = gˣ mod p ← 快速幂,约 log x 步
反向:给定 g 和 y,求 x ← ⚠️ 没有已知的快速方法
打个比方
单向性的直觉,可以借调颜料来理解。
给你一桶白漆,滴进去若干滴红色,搅匀——这一步又快又容易。可现在把调好的粉色交给别人,让他告诉你滴了几滴红:他只能一滴一滴地重新试,没有"反着搅"这种操作。
正向便宜、反向昂贵,而且这个不对称不是因为我们还没想到好办法——是这个运算本身没有留下可以倒推的结构。
⭐ 反向问题叫离散对数问题(DLP)。注意"离散"这个词:在实数里 log 很好算,因为函数是单调的,你可以二分逼近;而在模运算里 gˣ mod p 的取值毫无规律地跳来跳去,没有单调性可以利用——“接近答案"这个概念在这里根本不存在,所以二分、梯度、逼近全都用不上,只能穷举。
⚠️ 这一点值得停一下,因为它是整个公钥密码学的地基:难,不是难在计算量大,是难在没有比穷举更好的路。 如果哪天有人找到了那条路(比如足够大的量子计算机),塌掉的不是某一条链,是这一整套东西。
例:p = 23, g = 5
5¹ = 5 5² = 2 5³ = 10 5⁴ = 4
5⁵ = 20 5⁶ = 8 5⁷ = 17 5⁸ = 16
⚠️ 完全看不出规律。已知结果是 17,你只能一个个试才知道 x = 7。
为什么换成椭圆曲线
模幂运算的 DLP 有亚指数级的攻击算法(数域筛法),所以要达到 128 位安全,p 需要 3072 位。密钥太大了。
椭圆曲线上的离散对数(ECDLP)没有已知的亚指数算法,最好的攻击是通用的 Pollard’s rho,复杂度 √n。于是:
达到 128 位安全所需的密钥长度
模幂(RSA/DH):3072 位
椭圆曲线: 256 位 ⭐ 小 12 倍
⚠️ 这就是所有区块链都用椭圆曲线的唯一原因——不是它更安全,是同等安全下它更短。而链上每一个字节都要被所有人永久存储(第 3 讲)。
三、椭圆曲线:一套新的"加法”
⭐ 换成椭圆曲线之后,调颜料那个不对称一点没变——正向搅拌依然便宜,反向依然只能一滴一滴试。变的只是"颜料"换了一种,而这一种在同样的安全强度下所需的密钥短得多。
曲线长什么样
y² = x³ + ax + b (在实数上画出来是一条对称于 x 轴的曲线)
比特币和以太坊都用 secp256k1:
y² = x³ + 7 (即 a = 0, b = 7)
在有限域 F_p 上,p = 2²⁵⁶ − 2³² − 977
⚠️ 在有限域上它不是一条连续曲线,而是一堆离散的点(约 2²⁵⁶ 个)。但"实数上的图像"能帮助理解运算规则,所以先看图。
点加法的几何定义
│ ╱
│ ╱ ╲
P ●────╱─────╲──● Q
│ ╱ ╲
───┼─────────────●──── R' ← 直线与曲线的第三个交点
│ │
│ ↓ 关于 x 轴翻转
│ ● R = P + Q
规则:
P + Q :过 P 和 Q 画直线,找到与曲线的第三个交点,再关于 x 轴翻转
2P :直线退化为 P 点的切线,其余相同
P + (−P) :直线竖直,交于"无穷远点" O,⭐ O 就是这个加法的零元
⭐ 这套运算不是比喻——它满足交换律、结合律,有零元和逆元。曲线上的点在这个加法下构成一个交换群。 这就是全部数学基础。
(在有限域上,“画直线"变成解方程,公式是纯代数的,但群结构完全相同。)
标量乘法
有了加法,就有了"乘一个整数”:
kP = P + P + ... + P (k 次)
直接加 k 次是不可行的(k 可能有 2²⁵⁶ 那么大)。但用**倍加法(double-and-add)**只要 log₂ k ≈ 256 步:
算 13P:
13 = 1101₂
13P = 8P + 4P + 1P
= 2(2(2P)) + 2(2P) + P ← 只需 3 次倍乘 + 2 次加法
于是我们得到了想要的不对称性:
⭐ 正向:给定 k 和 P,算 kP → 约 256 次运算,微秒级
反向:给定 P 和 kP,求 k → ECDLP,约 2¹²⁸ 次运算
四、私钥、公钥、地址
曲线参数
secp256k1 固定了一个生成元 G(一个约定的曲线上的点)和它的阶 n(满足 nG = O 的最小正整数):
n = FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFE BAAEDCE6 AF48A03B BFD25E8C D0364141
≈ 1.158 × 10⁷⁷ ⚠️ 略小于 2²⁵⁶
私钥
私钥 d = 区间 [1, n−1] 内的一个随机整数 ← ⭐ 就这么简单,它只是一个 256 位随机数
⚠️ “随机"这两个字承载了全部安全性。 两个真实事故:
① 2013 年,Android 的 SecureRandom 实现有缺陷,
导致部分比特币钱包生成了重复的随机数 ⟹ 资金被盗。
② "脑钱包":用一句话的哈希当私钥(如 SHA256("correct horse battery staple"))。
⚠️ 攻击者早就把常见短语的哈希全算好了,这类地址会在几秒内被清空。
⭐ 私钥必须来自密码学安全的随机源。 Go 里是 crypto/rand,绝不能用 math/rand。
公钥
公钥 Q = d · G ← 一次标量乘法
公钥是曲线上的一个点,有 x 和 y 两个坐标,各 32 字节。编码有两种:
未压缩: 0x04 ‖ x(32) ‖ y(32) = 65 字节
压缩: 0x02 或 0x03 ‖ x(32) = 33 字节
⭐ 因为 y² = x³ + 7,给定 x 只有两个可能的 y(互为相反数),
只需 1 比特区分奇偶:y 为偶用 0x02,为奇用 0x03
同一个私钥用压缩和未压缩公钥会得到两个不同的比特币地址。 这是早期钱包迁移时资金"消失"的常见原因——钱其实还在,只是在另一个地址上。
地址
为什么不直接用公钥当地址? 三个原因:
① 更短:65 或 33 字节 → 20 字节
② ⭐ 多一层保护:在你花钱之前,公钥根本没有公开过,
链上只有它的哈希。即使 ECDLP 某天被攻破(比如量子计算机),
只有"已经花过钱"的地址会立刻暴露。
③ 可以加校验和,防止手抄错地址导致资金永久丢失
比特币:
公钥 ──SHA-256──▶ ──RIPEMD-160──▶ 20 字节 hash160
│
版本字节 ‖ hash160 ─────────────┘
│
├──▶ 双 SHA-256,取前 4 字节 = 校验和
│
版本 ‖ hash160 ‖ 校验和 ──Base58──▶ 1A1zP1eP5Q...
那 4 字节校验和的作用:随便改一个字符,校验失败的概率约 1 − 2⁻³²,即约 40 亿分之一才会漏过。抄错地址会被钱包当场拒绝,而不是把钱发到黑洞。
以太坊:
公钥(去掉 0x04 前缀的 64 字节)──Keccak-256──▶ 32 字节
│
取最后 20 字节 = 地址
以太坊原始地址没有校验和,就是 20 字节的裸十六进制。EIP-55 后来补了一个巧妙的方案:
把地址的十六进制字母按 Keccak 哈希的对应比特决定大小写。
0xde709f2102306220921060314715629080e2fb20 ← 全小写,合法但无校验
0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed ← 混合大小写,⭐ 带校验
⟹ 校验强度约 2⁻³⁰,且完全向后兼容(老钱包看到的还是同一个地址)
五、Go 实现
package keys
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/hex"
"fmt"
"math/big"
"golang.org/x/crypto/sha3"
)
// secp256k1 的参数。⚠️ Go 标准库不含这条曲线(它只有 NIST 系列),
// 生产代码应使用 github.com/decred/dcrd/dcrec/secp256k1 或 go-ethereum/crypto。
// 这里手动定义参数,用于说明原理。
func secp256k1() *elliptic.CurveParams {
c := &elliptic.CurveParams{Name: "secp256k1"}
c.P, _ = new(big.Int).SetString("fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f", 16)
c.N, _ = new(big.Int).SetString("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16)
c.B = big.NewInt(7)
c.Gx, _ = new(big.Int).SetString("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", 16)
c.Gy, _ = new(big.Int).SetString("483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8", 16)
c.BitSize = 256
return c
}
// GenerateKey 生成私钥和公钥。
// 必须使用 crypto/rand,绝不能用 math/rand——后者是可预测的伪随机数。
func GenerateKey() (*ecdsa.PrivateKey, error) {
curve := secp256k1()
d, err := rand.Int(rand.Reader, new(big.Int).Sub(curve.N, big.NewInt(1)))
if err != nil {
return nil, err
}
d.Add(d, big.NewInt(1)) // 保证 d ∈ [1, n−1]
x, y := curve.ScalarBaseMult(d.Bytes()) // 公钥 Q = d·G
return &ecdsa.PrivateKey{
PublicKey: ecdsa.PublicKey{Curve: curve, X: x, Y: y},
D: d,
}, nil
}
// CompressPubKey 把公钥压缩成 33 字节:前缀 + x 坐标。
// y 的奇偶性用前缀 0x02/0x03 表示,因为 y² = x³ + 7 只有两个解。
func CompressPubKey(pub *ecdsa.PublicKey) []byte {
out := make([]byte, 33)
if pub.Y.Bit(0) == 0 {
out[0] = 0x02
} else {
out[0] = 0x03
}
pub.X.FillBytes(out[1:]) // FillBytes 保证左侧零填充到 32 字节
return out
}
// EthAddress 计算以太坊地址:Keccak256(未压缩公钥去掉 0x04 前缀)[12:]
func EthAddress(pub *ecdsa.PublicKey) string {
raw := make([]byte, 64)
pub.X.FillBytes(raw[:32])
pub.Y.FillBytes(raw[32:])
h := sha3.NewLegacyKeccak256() // 不是 sha3.New256(),见第 4 讲第五节
h.Write(raw)
sum := h.Sum(nil)
return "0x" + hex.EncodeToString(sum[12:]) // 取最后 20 字节
}
func main() {
priv, _ := GenerateKey()
fmt.Println("私钥:", hex.EncodeToString(priv.D.Bytes()))
fmt.Println("压缩公钥:", hex.EncodeToString(CompressPubKey(&priv.PublicKey)))
fmt.Println("以太坊地址:", EthAddress(&priv.PublicKey))
}
六、从一个种子派生无数密钥:HD 钱包
如果每个地址都要单独备份一个私钥,用户体验会崩溃。BIP-32/39/44 定义了分层确定性钱包:
12 或 24 个助记词(BIP-39)
│ PBKDF2(2048 轮 HMAC-SHA512)
▼
512 位种子
│
▼
主私钥 + 主链码(chain code)
│ ⭐ 子密钥 = f(父密钥, 链码, 索引),确定性派生
├──▶ m/44'/0'/0'/0/0 第 1 个比特币地址
├──▶ m/44'/0'/0'/0/1 第 2 个
└──▶ m/44'/60'/0'/0/0 第 1 个以太坊地址
⭐ 备份 12 个词,就等于备份了未来所有地址的私钥。 这是所有现代钱包的基础。
⚠️ 两个要点:
- 助记词不是加密的,它就是种子的另一种写法。任何看到这 12 个词的人立刻拥有全部资产。
- 派生路径也要记。同一组助记词在不同路径下产生完全不同的地址——“助记词对了但钱不见了"通常是路径不对。
硬化派生:路径里那个撇号是干什么的
上面路径里 m/44'/0'/0'/0/0 有三个撇号,它标记的是硬化派生。要理解它,先看不带撇号的非硬化派生有什么好处:
非硬化派生满足一条很有用的等式:
子公钥 = f(父公钥, 链码, i) ⭐ 只用到【公钥】
⟹ 拿着 xpub(父公钥 + 链码)就能算出全部子【公钥】
⟹ 收银台可以为每一笔付款生成一个新地址,
而那台机器上【没有任何私钥】。
⚠️ 代价是一个相当致命的失效模式:
非硬化派生里,私钥侧的关系是加法:
子私钥 = 父私钥 + H(父公钥, 链码, i) (mod n)
⟹ 已知 子私钥,且已知 xpub(父公钥 + 链码):
父私钥 = 子私钥 − H(父公钥, 链码, i)
⟹ ⭐ 泄露【一个】子私钥 + xpub = 泄露【整棵子树】的全部私钥。
硬化派生(索引 ≥ 2³¹,写作 i')改用父私钥参与哈希:
子私钥 = f(父私钥, 链码, i)
⟹ 没有父私钥就算不出任何子密钥,上面那个反推不成立
⟹ 代价:xpub 推导子公钥的能力没了,观察钱包这条路走不通
⭐ 于是 m/44'/0'/0'/0/0 的形状就有了解释:前三层(用途 / 币种 / 账户)硬化,把不同账户互相隔离——一个账户出事不牵连别的;后两层不硬化,保留 xpub 在账户内部生成收款地址的便利。
⚠️ 这也解释了一条实践建议:xpub 看起来"只是公钥、可以随便给”,但它 ① 暴露你全部的历史与未来地址(隐私全失),② 一旦任何一个子私钥泄露就会连带父私钥。xpub 应当按敏感信息对待。
七、本讲小结
- ⭐ 公钥密码解决的是授权问题:验证方只需公钥,签名只有私钥能做,而公钥推不出私钥。
- 单向性来自离散对数:
gˣ mod p正着算快,反着求 x 只能穷举——因为模运算下没有单调性可利用。 - ⭐ 换椭圆曲线不是为了更安全,是为了更短:同等 128 位安全下,ECC 需要 256 位而模幂需要 3072 位。链上每字节都要被所有人永久存储。
- 椭圆曲线上的点构成一个交换群,“加法"由几何构造定义。标量乘法
kP用倍加法只需约 256 步,而逆运算需要2¹²⁸步。 - 私钥就是一个 256 位随机数。“随机"承载全部安全性——Android SecureRandom 事故、脑钱包被秒清空,根因都在这里。Go 里必须用
crypto/rand。 - 公钥可压缩到 33 字节,因为给定 x 只有两个 y,用 1 比特记奇偶。压缩与非压缩产生不同的地址。
- 地址是公钥的哈希,三个原因:更短、加校验和、以及在花钱之前公钥从未公开——这是面对量子威胁时的一层缓冲。
- 校验和的价值:比特币 Base58Check 的 4 字节校验让抄错地址被漏过的概率约 40 亿分之一;EIP-55 用大小写编码达成同样效果且向后兼容。
- HD 钱包让 12 个助记词等价于全部私钥。助记词不是加密的,且派生路径必须一并记住。
- ⭐ 非硬化派生的子私钥是父私钥加一个哈希,所以泄露一个子私钥 + xpub 就能反推父私钥、进而拿到整棵子树。硬化派生改用父私钥参与哈希堵住这条路,代价是 xpub 推导子公钥的能力失效。
- ⚠️ xpub 不是"可以随便给的公钥”:它暴露全部历史与未来地址,且与上一条的失效模式叠加。按敏感信息对待。
思考题
- 为什么不能用密码或对称密钥来做链上授权?请从"验证者需要知道什么"来回答。
- 用
p = 23, g = 5手算5⁹ mod 23。然后反过来:已知5ˣ ≡ 6 (mod 23),求 x。哪个方向更花时间? - 为什么椭圆曲线能用更短的密钥达到同等安全?这个优势对区块链具体意味着什么?
- 说明倍加法为什么只需
log₂ k步。手算2025P需要多少次倍乘和加法? - 压缩公钥只存 x 和 1 比特奇偶。给定 x,写出恢复 y 的步骤(提示:需要在有限域上开平方)。
- 如果一个地址从未花过钱,它的公钥在链上出现过吗?这对"量子计算机会不会偷光比特币"这个问题意味着什么?
- Base58Check 的校验和是 4 字节。估算随机改动一个字符而校验仍然通过的概率。
- 两个人用同一组助记词但不同派生路径,会看到同样的余额吗?如果一个用户说"助记词是对的但钱没了”,你会怎么排查?
- 非硬化派生里,为什么"子私钥 = 父私钥 + H(…)“这条加法关系会让子私钥泄露升级成父私钥泄露?请把反推的那一步写出来。
- 一个交易所把某个账户的 xpub 交给了第三方对账服务商。请分别说明这带来的隐私后果和资金安全后果,并指出哪一层的撇号本可以限制损失范围。