公私钥与数字签名:从私钥到地址(师生对话实录)
Web3 区块链系列 · 阶段 0 · 地基与密码学 · 第 3/57 篇
上一篇:《哈希函数与默克尔树:区块链的指纹术》 · 下一篇:《钱包与助记词:BIP-39/32/44》
学习大纲:《Web3 区块链学习总纲》
写在前面
上一篇哈希解决了「改没改过看得见」,但我心里还悬着上一篇的第 2 课:账本人手一份、谁都能往里写——凭什么你写不了我的钱?「这是谁的」这个问题,哈希答不了,得靠另一套东西:非对称密码学。
继续对话老办法:AI 当老师我当学生,每课一个概念,有问题就打断。本篇从 256 位随机数一路推到 0x 开头的地址,再亲手签名、验签、篡改、恢复——把区块链的「身份系统」整条跑通。中途老师还当堂踩了一个库的坑,也如实记下来了(很有价值)。
课程路线图:
① 私钥:一个 256 位随机数 → ② 公钥:曲线上的点 → ③ 地址:指纹的指纹 → ④ 签名:r、s、v → ⑤ 验证与篡改 → ⑥ ecrecover:从签名反查签名者 → ⑦ 消息签名 vs 交易签名
环境:WSL2 Ubuntu-22.04 + Python 3.10(pip3 install pycryptodome ecdsa)。本篇代码每次运行生成全新随机私钥,文中的私钥地址只在当次运行有效——这也正是「随机数」的含义。官方背景:ethereum.org — Accounts、以太坊黄皮书附录 F(ECDSA)。
第 1 课:私钥——就是一个 256 位的随机数
🧑🏫 老师:
先卸掉神秘感:以太坊的私钥,本质就是 64 个十六进制字符(32 字节、256 位)的随机数。生成它一行代码:
import os
sk_bytes = os.urandom(32) # 32 字节 = 256 位熵
sk_hex = sk_bytes.hex()
print("私钥(hex):", sk_hex)
print("长度:", len(sk_hex) * 4, "bit;可能的取值数 ≈ 1.16 × 10^77")
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
print("合法范围: 1 ≤ 私钥 < secp256k1 阶 n")
print("本把私钥有效:", 1 <= int(sk_hex, 16) < n)私钥(hex): f19b9d0222db340b68c15d62529ba2d0f804164994663ecb3b0a7aa089e79c2b
长度: 256 bit;可能的取值数 ≈ 1.16 × 10^77
合法范围: 1 ≤ 私钥 < secp256k1 阶 n = 0xffffffffffffffff …
本把私钥有效: True三个要点:
- 随机数是全部的安全性。必须用密码学安全的随机源(
os.urandom),不能用时间戳、「自己的生日+手机号」这类伪随机——私钥可预测,一切归零; - 数量级就是安全性的来源:2²⁵⁶ ≈ 1.16 × 10⁷⁷ 个可能。全网算力穷举它,比把地球上每粒沙子编号再全抽一遍还离谱——上一篇插问 1 的 2²⁵⁶ 这里原样适用;
- 那个
n叫曲线的阶(secp256k1 上合法点的个数),私钥随机落在[1, n)里即可——os.urandom(32)命中范围外(0 或 ≥ n)的概率小到忽略,严格实现会重抽。
私钥没有任何格式玄学:它就是数。你拿铅笔在纸上写对 64 个 hex 字符,那串数就是能花你所有资产的钥匙——「Not your keys, not your coins」里的 key,说的就是它。
一句话收口:私钥 = 密码学随机数生成的 256 位整数;随机性即安全性,数量级(10⁷⁷)即不可穷举性。
第 2 课:公钥——私钥在椭圆曲线上「跳」出来的点
🧑🎓 学生: 有了私钥,公钥怎么来的?也是随机生成一对吗?
🧑🏫 老师:
不是一对独立的随机数——公钥是从私钥算出来的。比特币和以太坊都用同一条曲线 secp256k1,规则一句话:
公钥 = 私钥 × G(G 是曲线上一个公开的固定点,叫生成点)
import ecdsa
sk = ecdsa.SigningKey.from_string(sk_bytes, curve=ecdsa.SECP256k1)
vk = sk.get_verifying_key()
pub = b"\x04" + vk.to_string() # 04 || X || Y
print("公钥(未压缩,04 开头):", pub.hex()[:32], "…")
print("长度:", len(pub), "字节")公钥(未压缩,04 开头,64 字节): 04978b0557200667ccbf7de47dae477 …
长度: 65 字节「私钥 × G」是什么乘法?不是普通数字相乘——把 G 这个点在曲线上反复「加点」(椭圆曲线的加法是几何规则:连两点、延长交于第三点、取对称),加「私钥」那么多次,落在哪个点,那个点就是公钥。你不需要会算这个加法(库来干),只需要记住它的不对称性:
私钥 --(乘 G,快,一条指令)--> 公钥 ✔ 容易
公钥 --(反推私钥 = 椭圆曲线离散对数)--> 私钥 ✘ 与哈希逆向一样是宇宙级难题这个「过去容易、回来不可能」和第 2 篇哈希的单向性是同一种味道——非对称密码学的全部魔法都建立在这种单向门上。也正因为公钥可以随便公开(反推不出私钥),它才能满世界分发。
(一个细节先记着:以太坊实际存的是 64 字节的 X||Y 形式,04 前缀是「未压缩」的标记字节;压缩公钥 33 字节,0.4 篇的助记词派生会再遇到它。)
一句话收口:公钥 = 私钥 × G,一条指令算过去、宇宙级难度算回来;能公开的钥匙和必须私藏的钥匙,就是这样一对。
第 3 课:地址——公钥的指纹,取后 20 字节
🧑🏫 老师:
最后一步,把 65 字节的公钥压成你天天见的 0x… 地址——用的正是上一篇的哈希:
from Crypto.Hash import keccak
def keccak256(b):
k = keccak.new(digest_bits=256); k.update(b); return k.digest()
pub_hash = keccak256(vk.to_string()) # 对 X||Y(不含 04)做 keccak256
addr = "0x" + pub_hash[-20:].hex()
print("keccak256(公钥) :", pub_hash.hex())
print("取后 20 字节 :", pub_hash[-20:].hex())
print("以太坊地址 :", addr)keccak256(公钥) : 1fac673888302867883091897f91ea54b65343b25cad15b0881adad290c5763c
取**后 20 字节** : 7f91ea54b65343b25cad15b0881adad290c5763c
以太坊地址 : 0x7f91ea54b65343b25cad15b0881adad290c5763c整条推导链钉成图——这就是占位大纲要的那张「从 256 位随机数到地址」的流程图:
256 位随机数 ──(×G on secp256k1)──► 公钥点 (X, Y)
│
keccak256(X||Y)
│
取哈希的后 20 字节
│
▼
0x7f91ea54…5763c(20 字节 = 40 个 hex 字符)推导是纯确定性的:同一私钥永远推出同一地址。验证:
sk2 = ecdsa.SigningKey.from_string(bytes.fromhex(sk_hex), curve=ecdsa.SECP256k1)
addr2 = "0x" + keccak256(sk2.get_verifying_key().to_string())[-20:].hex()
print("重新推导的地址:", addr2)
print("两次一致:", addr2 == addr)重新推导的地址: 0x7f91ea54b65343b25cad15b0881adad290c5763c
两次一致: True对照比特币:它的地址是 SHA-256 → RIPEMD-160 双哈希再 Base58Check 编码(所以是 1/3/bc1 开头),链不同、套路相同——地址都是公钥的哈希摘要。另一个重要对照记在心上:比特币地址是 HASH160(公钥),直到花钱那一刻公钥才公开;以太坊的地址本身就是公钥哈希,但同样的原则成立——公钥在第一笔交易签名之前并不公开(量子议题 1.4 篇会用到这个伏笔)。
一句话收口:地址 = keccak256(公钥) 的后 20 字节;从随机数到地址是一条单向流水线,可无限重放、永不可逆。
插问 1:为什么不直接拿公钥当地址?多套一层哈希图什么?
🧑🎓 学生: 公钥 65 字节、地址 20 字节,都是能公开的东西——中间再哈希一次,不是多此一举吗?
🧑🏫 老师:
三层好处,一层比一层实用:
- 短。公钥 65 字节,地址 20 字节——转账、显示、索引都省 3 倍空间。链上每个字节都是全体节点永久存储的成本,压缩是刚需;
- 安全分层与更好的匿名性。地址是公钥的哈希,从地址推不出公钥(单向性)。这意味着你在地址里躺着时,攻击者连「用椭圆曲线攻击公钥」的入口都看不到——公钥只有在你第一次签名后才公开。多一层哈希 = 多一道护城河,也让你能用同一个私钥派生出多个不同地址(0.4 篇),账本上更难被串在一起分析;
- 校验。以太坊的地址有 EIP-55 大小写校验(把地址 hex 再哈希一遍决定每个字母大小写),抄错一个字符会被钱包发现——纯小写地址没有这层保护(我们脚本输出的是小写原形,MetaMask 显示的混排就是 EIP-55)。
所以「公钥的指纹再取 20 字节」不是绕路,是把公开信息做得又短、又隐藏、又可校验。
一句话收口:地址 = 公钥的短指纹:省空间、藏公钥、可校验——公开世界里的「化名」。
第 4 课:签名——把「我授权」焊死在消息上
🧑🏫 老师:
零件齐了,组装本篇的主菜:数字签名。场景:我要证明「这条消息确实出自我、而且一字未改」——但不泄露私钥。
流程是:对消息哈希,再用私钥签这个哈希:
msg = b"pay Alice 100 ETH, nonce=1"
msg_hash = keccak256(msg)
sig = sk.sign_digest(msg_hash, sigencode=ecdsa.util.sigencode_string_canonize)
r, s = sig[:32], sig[32:]
print("消息:", msg.decode())
print("keccak256(消息):", msg_hash.hex()[:32], "…")
print("签名 r:", r.hex()[:32], "…")
print("签名 s:", s.hex()[:32], "…")
print("签名总长:", len(sig), "字节(r 32 + s 32;以太坊交易再补 1 字节 v)")消息: pay Alice 100 ETH, nonce=1
keccak256(消息): 8a872216950939e622597baa5b1518dd …
签名 r: d302db341bdb1c90c88787dc71068777 …
签名 s: 52eb42f37f2d10f5aaa77a38a757f5e1 …
签名总长: 64 字节(r 32 + s 32;以太坊交易再补 1 字节 v)三个参数各是什么:
- r(32 字节):签名过程中随机点 R 的 x 坐标——可以粗略理解为「这次签名随机选中的位置」;
- s(32 字节):把消息哈希、私钥、r 揉在一起算出的方程解——证明「解方程的人知道私钥」,但等式里私钥被随机数遮住了,解不出它;
- v(1 字节):一个辅助位(27/28 或 0/1),第 6 课马上看到它的用途。
为什么先哈希再签?两个原因:签名算法需要固定长度的输入;更重要的是签名签的是「消息的指纹」——验证时对消息重新算指纹即可比对,上一篇的雪崩效应顺带保证了「改一个字,签名立刻对不上」。
一句话收口:签名 = 私钥对 keccak256(消息) 生成的 65 字节凭证(r、s、v);它证明签名者知道私钥,却不交出私钥。
第 5 课:验证——任何人可验、篡改必死
🧑🎓 学生: 「证明知道私钥」这个说法我还是没抓住——验证方到底在验什么?篡改消息真的能被发现吗?
🧑🏫 老师:
两问都当场验。第一问:用公钥验证——公钥是公开的,所以任何人(不需要「权威机构」)都能验:
ok = vk.verify_digest(sig, msg_hash, sigdecode=ecdsa.util.sigdecode_string)
print("用公钥验证签名:", "通过" if ok else "失败")用公钥验证签名: 通过第二问:把消息里的 100 偷改成 10000,拿原签名去验新消息:
bad_hash = keccak256(b"pay Alice 10000 ETH, nonce=1")
try:
ok2 = vk.verify_digest(sig, bad_hash, sigdecode=ecdsa.util.sigdecode_string)
print("篡改后验证:", "通过" if ok2 else "失败")
except ecdsa.BadSignatureError:
print("篡改后验证: 失败(BadSignatureError)")篡改后验证: 失败(BadSignatureError)篡改 100 → 10000,指纹雪崩(第 2 篇),签名方程立即不成立。把这套东西放回第 1 篇的账本场景,防伪闭环就出来了:
转账消息 + 签名(r,s,v)──广播──► 全网每个节点
│ 用签名恢复出公钥 → 推出地址
│ 地址 == 转账的付款方? ── 否 → 拒绝
│ 消息重新哈希能对上签名? ── 否 → 拒绝
▼ 是 → 这笔确实是「付款方本人授权」的防伪造(没有私钥造不出签名)与防抵赖(签了名谁都验得了,赖不掉)同时达成——这就是占位大纲验收标准里的那句话的机制版。
一句话收口:验证 = 任何人拿公钥复核签名方程;改一个比特方程即崩——防伪造与防抵赖,一套 65 字节全包。
插问 2:签名不泄露私钥,我懂了。但同一个消息签两次,签名一样吗?
🧑🎓 学生: 如果每次签同一句话得到的签名一模一样,那签名本身不就成了「可复制的凭证」——别人把签名抄下来重放不就行了?
🧑🏫 老师:
好问题,两层回答。
第一层:签名确实每次都不一样。 ECDSA 每次签名都引入一个一次性随机数 k,r 就是 k 选出的点的坐标——k 不同,签名完全不同:
同一私钥、同一消息,签两次 → r、s 各不相同(k 每次随机)这也是「签名 ≠ 消息的哈希」的关键区别:哈希是确定性的,签名故意做成非确定性的。
第二层:这个 k 是历史上真实炸过的地方。 ECDSA 方程里私钥和 k 满足 s = k⁻¹(e + r·私钥)——如果两次签名用了相同的 k,两个方程联立,私钥直接被解出来。两个著名事故:索尼 PS3 的 ECDSA 签名硬编码了同一个 k,2010 年被高中生从两个签名里解出私钥,整个签名体系沦陷;2013 年部分 Android 比特币钱包因随机数源缺陷重复 k,私钥被盗、资产被转。现代实现用 RFC 6979 确定性 k(用私钥和消息哈希推导 k,看似「不随机」实则永远不重复)根治了这类事故——我们脚本用的 sign_digest 背后就是它。
至于「抄下签名重放」:签名变了也挡不住重放(拿旧的合法签名原样再发)。解药不在签名里,在消息里——以太坊的每笔交易带 nonce(账户交易计数器),同一签名对应的 nonce 已被用过,重放立即被拒。2.2 篇读交易字段时会看到它。
一句话收口:签名每次不同靠一次性随机数 k;k 重复 = 私钥泄露(索尼/Android 事故);防重放靠交易 nonce,不靠签名本身。
第 6 课:ecrecover——从签名反查出签名者
🧑🏫 老师:
本篇最后一块拼图,也是以太坊最有工程味的一招。前面的验证都是「我给你公钥,你验证签名」。以太坊反过来:不给公钥——验证方直接从 (r, s, v) 里把签名者的公钥算回来,再推出地址,和交易里声明的发送者比对:
candidates = ecdsa.VerifyingKey.from_public_key_recovery_with_digest(
sig, msg_hash, curve=ecdsa.SECP256k1, sigdecode=ecdsa.util.sigdecode_string)
print("数学上能和这对 (r,s) 对上的公钥候选:", len(candidates), "个")
for i, cand in enumerate(candidates):
cand_addr = "0x" + keccak256(cand.to_string())[-20:].hex()
print(f" 候选 {i}: 地址 {cand_addr} {'← 真正的签名者' if cand_addr == addr else ''}")数学上能和这对 (r,s) 对上的公钥候选: 2 个
候选 0: 地址 0x7f91ea54b65343b25cad15b0881adad290c5763c ← 真正的签名者
候选 1: 地址 0xac0933d8907af191d9a021c8de07e92d35687a97一对 (r, s) 数学上对应两个候选公钥(椭圆曲线上 x 坐标对应上下两个点)——v 就是用来挑出正确那个的(27/28 对应两个候选)。这就是 v 参数的全部身世。
这个操作在以太坊里是个预编译合约 ecrecover(hash, v, r, s) → address:EVM 执行到它,直接返回恢复出的地址。妙处在省数据:交易里不需要附带 65 字节公钥(v,r,s 已经够了,公钥是「算」回来的);更妙在催生了链下签名授权模式——合约发一条消息让你在钱包里签名(不上链、不花 gas),合约将来用 ecrecover 验证「确实是本人签的」再执行——白名单、permit 系授权(4.1 篇 ERC-20 的 approve 系列会再遇到)全靠它。占位大纲里那句「合约如何从签名恢复出地址」,答案就是本课这五行代码。
一句话收口:ecrecover = 从 (hash, v, r, s) 直接算回签名者地址;v 负责二选一;链下签名 + 链上恢复 = 一整类授权模式的发动机。
插问 3:老师刚才是不是踩了个坑?
🧑🎓 学生: 我注意到实验一开始报错了两次——先说参数传反、后来又有两个候选都「不对」。这两次翻车是怎么回事?跟知识点有关吗?
🧑🏫 老师:
跟知识点关系很大,值得当堂复盘。第二处翻车的真相是:我一开始用的是库的 sign(data) 方法——它会把传入的 data 再哈希一层(默认 sha1)再签名;而恢复接口把参数当作「已经是哈希的 digest」。一边签的是 sha1(keccak256(msg)),一边恢复时用 keccak256(msg) 当 digest——两边对哈希的理解不一致,恢复出来的候选自然全错。改成 sign_digest / verify_digest(明确「参数就是 digest,别再哈希」)后立刻全通。
这个坑的普适教训比坑本身值钱:密码学库的 API 语义必须逐字确认——「签名的输入是消息还是哈希」,一个字的差异就是签名体系的整体错位。 以太坊真实的流程是「对 keccak256 后的交易哈希直接 ECDSA」,所以任何中间件多哈或少哈一层,产出的签名在链上就验证不过。以后用 cast/foundry 的 cast wallet sign(它按 EIP-191 处理)和合约里的 ecrecover(按原始哈希处理)对接时,还会撞到这个主题的变体——到 4.1 篇的 permit 再正面处理它。
一句话收口:
sign(data)会再哈希一层、sign_digest(d)不会——密码学 API 的输入语义要逐字核对,「哈希了几层」错了整栋楼都歪。
第 7 课:消息签名与交易签名——一字之差,防的是你
🧑🏫 老师:
最后把「签名」这个词在以太坊里的两个语境分开,它们哈希的东西不一样:
| 交易签名 | 消息签名(personal_sign) | |
|---|---|---|
| 签的对象 | 序列化后的交易(含 to/value/data/nonce/…) | 任意消息(常是 UTF-8 文本) |
| 前缀 | 无(交易结构本身就是上下文) | "\x19Ethereum Signed Message:\n32"(EIP-191) |
| 验证者 | 每个节点(执行交易前) | DApp / 合约(ecrecover) |
| 花 gas 吗 | 签名随交易上链,花 | 链下签,不花 |
为什么要加那个怪前缀?防钓鱼:没有前缀时,一段「消息」和一段「交易原始字节」可能长得一模一样——恶意网站让你「签个登录消息」,实际是让你签一笔转账。EIP-191 前缀保证「消息签名」和「交易签名」在数学上不可能是同一个哈希,签了消息也变不成交易。钱包弹出确认框里那些醒目的警告文案,背后就是这道隔离带。
一句话收口:交易签名签交易结构、消息签名带 EIP-191 前缀——两套哈希域互不相通,防止「签消息」被偷换成「签转账」。
小结
一条流水线,从随机数到身份系统:
- 私钥 =
os.urandom(32),一个 256 位随机数;安全性全部押在随机性上。 - 公钥 = 私钥 × G(secp256k1);过去一条指令,回来宇宙级难度。
- 地址 = keccak256(公钥) 后 20 字节——短、藏公钥、可校验(EIP-55)。
- 签名 = r + s + v,共 65 字节;证明知道私钥而不交出私钥。
- 验证 = 任何人拿公钥复核方程;改一个比特即失败——防伪造 + 防抵赖。
- k 的纪律:每次签名的一次性随机数不能重复(索尼/Android 事故),现代实现用 RFC 6979。
- ecrecover:从 (hash, v, r, s) 反算签名者地址,v 负责两个候选二选一。
- 踩坑实录:
sign会多哈一层、sign_digest不会——API 的哈希语义要逐字核对。 - 两套签名域:交易签名与 EIP-191 消息签名隔离,防「签消息」变「签转账」。
验收清单(做完再进下一篇):
思考题:为什么私钥丢了就彻底没救,而传统网站的密码忘了能重置?(提示:传统系统里「你」的最终证明是手机号/身份证——中心机构还留着一份;区块链上「你」的唯一证明就是私钥本身,没有第二份。)
下一篇:《钱包与助记词:BIP-39/32/44》——12 个英文单词凭什么等价于那串 64 位的随机数,还能长出无数把钥匙。
本篇实验脚本(可照抄)
# keys.py —— 私钥 → 公钥 → 地址 → 签名 → 验证 → 恢复
import os, ecdsa
from Crypto.Hash import keccak
def keccak256(b):
k = keccak.new(digest_bits=256); k.update(b); return k.digest()
sk_bytes = os.urandom(32) # ① 私钥
sk = ecdsa.SigningKey.from_string(sk_bytes, curve=ecdsa.SECP256k1)
vk = sk.get_verifying_key()
addr = "0x" + keccak256(vk.to_string())[-20:].hex() # ③ 地址
print("地址:", addr)
msg_hash = keccak256(b"pay Alice 100 ETH, nonce=1") # ④ 签名
sig = sk.sign_digest(msg_hash, sigencode=ecdsa.util.sigencode_string_canonize)
print("签名:", sig.hex()[:32], "… (r||s,65 字节含 v)")
print("验证:", vk.verify_digest(sig, msg_hash, # ⑤ 验证
sigdecode=ecdsa.util.sigdecode_string))
cands = ecdsa.VerifyingKey.from_public_key_recovery_with_digest( # ⑥ 恢复
sig, msg_hash, curve=ecdsa.SECP256k1, sigdecode=ecdsa.util.sigdecode_string)
print("恢复候选:", ["0x" + keccak256(c.to_string())[-20:].hex() for c in cands])参考资料
- ethereum.org — Accounts(地址推导与 EIP-55)
- secp256k1 与 ECDSA — Bitcoin Wiki
- RFC 6979 — 确定性 ECDSA nonce(插问 2)
- EIP-191 — personal_sign 前缀(第 7 课)
- 索尼 PS3 ECDSA 固定 k 事故:2010 年 fail0verflow 披露(插问 2)
- 本机:WSL2 Ubuntu-22.04 + Python 3.10(ecdsa + pycryptodome)