一、需求:只有我能花我的钱

你要向一屋子陌生人证明一件事:这笔钱是我的,我同意花掉它。

难点在于,你不能靠"出示密码"——只要你把密码说出来,屋里每个人都能拿它去花你的钱。你需要一种说出来也不会泄密的证明方式

上一讲解决了"数据完整性"。现在处理另一个问题:授权

链上有一笔资金标记为"属于 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          ← 一次标量乘法

公钥是曲线上的一个点,有 xy 两个坐标,各 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 不是"可以随便给的公钥”:它暴露全部历史与未来地址,且与上一条的失效模式叠加。按敏感信息对待。

思考题

  1. 为什么不能用密码或对称密钥来做链上授权?请从"验证者需要知道什么"来回答。
  2. p = 23, g = 5 手算 5⁹ mod 23。然后反过来:已知 5ˣ ≡ 6 (mod 23),求 x。哪个方向更花时间?
  3. 为什么椭圆曲线能用更短的密钥达到同等安全?这个优势对区块链具体意味着什么?
  4. 说明倍加法为什么只需 log₂ k 步。手算 2025P 需要多少次倍乘和加法?
  5. 压缩公钥只存 x 和 1 比特奇偶。给定 x,写出恢复 y 的步骤(提示:需要在有限域上开平方)。
  6. 如果一个地址从未花过钱,它的公钥在链上出现过吗?这对"量子计算机会不会偷光比特币"这个问题意味着什么?
  7. Base58Check 的校验和是 4 字节。估算随机改动一个字符而校验仍然通过的概率。
  8. 两个人用同一组助记词但不同派生路径,会看到同样的余额吗?如果一个用户说"助记词是对的但钱没了”,你会怎么排查?
  9. 非硬化派生里,为什么"子私钥 = 父私钥 + H(…)“这条加法关系会让子私钥泄露升级成父私钥泄露?请把反推的那一步写出来。
  10. 一个交易所把某个账户的 xpub 交给了第三方对账服务商。请分别说明这带来的隐私后果和资金安全后果,并指出哪一层的撇号本可以限制损失范围。