T2 · 密码学基础
为什么一串签名可以证明「是我」?
- 练习的能力
- BuilderProtocol Literacy
- 动手
- 本地生成密钥对,对一段消息签名,再用公钥验签;故意改一个字节,看验签怎样失败。
- AI Lab
- 让 AI 写一个 Merkle 证明的验证函数,自己构造一个伪造证明,确认它会被拒绝。
一个现实问题
登录你的 DApp 不需要密码。
用户点「连接钱包」,弹出一个框,点一下「签名」,就登录了。你的后端收到两样东西:一个地址,和一串 132 个字符的十六进制字符串。凭这两样东西,后端就敢认定这个请求来自那个地址的主人。
换成一个传统后端,同一件事需要:注册流程、数据库里的口令摘要、一次比对、一个会话。这里一样都没有。服务器从来没见过这个用户,也不保存关于他的任何秘密。
更让人不安的是那串十六进制是公开的。它走在 HTTP 请求里,可能被记进访问日志,可能被中间人看到,可能被你自己不小心打印到控制台。
它被看到之后,为什么别人还是没法冒充你?
一串任何人都能抄走的字符,凭什么能证明「是我」?
思想实验
把问题缩到最小:你在一个陌生城市,要向一个素不相识的人证明「这张纸条是我写的」。
三个约束:你们没有任何共同的秘密;你不能把自己的秘密告诉他;这张纸条可能被任何人看到和复制。
第一版:约定一个暗号,写在纸条末尾。
问题立刻出现。他一旦看到暗号,就能自己写一张一模一样的纸条。**验证者同时变成了伪造者。**这不是实现细节的问题,是结构上的死结。
第二版:不写暗号本身,写一个由暗号算出来的、算不回去的乱码。
他没法从乱码反推暗号了。但他也不需要反推——他把这串乱码原样抄到另一张纸条后面就行。证明和内容没有绑定,抄一次就能永远复用。
第三版:把暗号和纸条内容拼在一起,再算成乱码。
现在证明和内容绑定了,换一张纸条乱码就对不上。可是验证者要想重算一遍,他必须知道暗号——于是又回到第一版。
要么他能验证,要么他不能伪造,二者不可兼得。
一个普通的单向函数到这里就走到头了。要打破这个死结,需要一样更奇怪的东西:
一对数字。一个用来生成证明,一个用来检查证明,而且从「检查用的那个」推不出「生成用的那个」。
有了它,你把检查用的那个公之于众——谁都能验证;生成用的那个你一辈子不交给任何人——谁都不能伪造。死结解开了。
现在回头看开头那个问题:那串 132 个字符不是你的秘密,它是用秘密算出来的一个一次性结果。看到它没有用,因为它只对那一条消息有效。
你来决定
后端要确认「这个请求来自地址 A 的主人」。你选哪一种?
观察结果
把四种做法放在一起,暴露出来的是同一组维度:
| 方案 | 验证者需要知道秘密吗 | 能绑定具体消息吗 | 能被重放吗 | 泄露后果 |
|---|---|---|---|---|
| 直接发秘密 | 需要 | 不能 | 随便重放 | 资产全部丢失 |
| 发秘密的摘要 | 不需要 | 不能 | 随便重放 | 身份可被冒充 |
| 发「秘密加消息」的摘要 | 需要 | 能 | 不能 | 双方都能伪造 |
| 发一个可公开验证的证明 | 不需要 | 能 | 取决于消息内容 | 只影响这一条消息 |
最后一行有一个含糊的地方,值得单独拎出来:能不能被重放,不取决于密码学,取决于你在消息里写了什么。
一条签名永远有效。如果你签的是「登录」两个字,那这条签名可以在任何时刻、任何网站、任何一条链上被重复使用。要让它只用一次、只在一个地方有效,你必须把随机数、域名、链的编号、过期时间写进被签的内容里。
这不是密码学帮你做的事。这是你自己要设计的事。
建立模型
三个原语,加一个组合结构。
**原语一:单向摘要。**任意长度的输入,固定长度的输出。同样的输入永远得到同样的输出;改一个字节,输出面目全非;从输出倒推输入在计算上不可行。
它的用处有三个:给任意数据一个稳定的短名字;检测内容有没有被改;把「很多数据」压缩成「一个值」。
**原语二:一对密钥。**私钥是一个随机数,公钥由私钥单向推导。正推很快,反推不可行。地址又是公钥的摘要——所以地址、公钥、私钥是一条单向链:
- 随机数 = 私钥
- 椭圆曲线运算 → 公钥
- 摘要并截断 → 地址
**原语三:签名与验签。**签名把「私钥」和「消息摘要」吃进去,吐出一个数字(在以太坊上是 r、s、v 三段,共 65 字节)。验签把「消息」和「签名」吃进去,反推出签名者的公钥,再算成地址。
注意这个方向:验签不是「拿公钥去解密」,而是从签名里恢复出公钥。所以你不需要事先知道对方是谁,你是通过验证过程才知道他是谁的。这也是为什么以太坊的交易里不需要携带发送方地址——它是算出来的。
一次完整的身份证明:
- 服务端给出一条含随机数与域名的消息
- 钱包对它的摘要签名
- 客户端把消息与签名一起送回
- 服务端恢复出地址
- 比对并作废这个随机数
组合结构:把很多条数据压成一个值,并且还能单独证明其中任意一条。
场景:一份 30 万个地址的名单,要让链上合约能判断「某个地址在不在名单里」。全部写进合约,成本高到不可接受。
做法:把 30 万条数据各自摘要,得到 30 万个叶子;相邻两个拼起来再摘要,得到 15 万个;一层层往上,最后剩下一个值。链上只存这一个值。
用户领取时提交两样东西:自己那条数据,以及从自己的叶子到顶端这一路上,每一层「兄弟」的值——30 万条数据大约只需要 19 个。合约照着这条路径重算一遍,算出来的顶端值等于链上存的那个,就放行。
它解决的问题一句话:在不给出全部数据的情况下,证明某一条数据确实在里面。
这个结构在链上无处不在:区块头里用它承诺整个交易列表,空投用它承诺名单,跨链桥用它承诺一批消息。形状都一样。
它叫什么
把任意长度的输入映射成固定长度输出的单向函数。同输入必然同输出,改一个比特输出就完全不同,反推不可行。
以太坊用的是 Keccak-256,比特币用 SHA-256。它们不是同一个函数,把两者混用是新手最常见的错误之一,算出来的地址会完全对不上。
一个随机数。它不是「生成的」,是「抽的」——随机性的质量直接决定安全性。
它同时是身份和资产控制权。这一点在传统系统里没有对应物:那边重置密码就能找回账号,这边丢了就是永久丢失。
由私钥单向推导出的值,可以公开。地址是公钥再做一次摘要并截断得到的。
所以「暴露公钥」是安全的,「暴露地址」更是本来就要公开的。危险的只有私钥,以及任何能推出私钥的东西——包括签名时用到的随机数。
以太坊与比特币使用的签名算法,曲线是 secp256k1。Solana 用的是另一套(Ed25519),密钥长度和签名格式都不同。
ECDSA 每次签名要用一个一次性随机数。这个随机数重复使用一次,私钥就能被直接解出来——下面「真实案例」里有真实事故。
对某条消息的一次性证明。在以太坊上是 65 字节:r 和 s 各 32 字节,v 是 1 字节的恢复标识。
签名只能证明两件事:这条消息没被改过;签名者在签的那一刻持有私钥。它不能证明签名者理解了自己签了什么。
把一个集合逐层两两摘要,压成一个根值的结构。给出一条数据加上一条从叶子到根的路径,任何人都能独立验证这条数据属于这个集合,而不必拿到整个集合。
路径的长度是数据条数的对数——一百万条数据,二十来个哈希就够。这个「对数级」是它值钱的全部原因。
动手
全程在本机,不涉及任何链、任何测试网、任何真实资产。这一章不发交易,生成的密钥只用于实验,做完请删掉。
生成一个 secp256k1 的私钥,再从它导出公钥。
openssl ecparam -name secp256k1 -genkey -noout -out priv.pem
openssl ec -in priv.pem -pubout -out pub.pem
cat priv.pem pub.pem两个文件的关系是单向的:有 priv.pem 随时能重新生成 pub.pem,反过来不行。试着删掉 pub.pem 再生成一次,确认结果一模一样——这说明公钥里没有任何额外信息。
准备一条消息并签名。
printf 'transfer 100 to alice' > msg.txt
openssl dgst -sha256 -sign priv.pem -out sig.bin msg.txt
xxd sig.bin | head看一眼 sig.bin 的长度。它和消息长度无关——签的不是消息,是消息的摘要。
用公钥验签。
openssl dgst -sha256 -verify pub.pem -signature sig.bin msg.txt应该输出 Verified OK。注意这里只用到了 pub.pem,私钥完全没参与。这就是那个死结被解开的地方。
改一个字节,再验一次。
printf 'transfer 100 to blice' > tampered.txt
openssl dgst -sha256 -verify pub.pem -signature sig.bin tampered.txt失败。改的是名字里的一个字母,摘要完全变了,签名就对不上。
再试一种:消息不动,把签名文件改一个字节,同样失败。签名验证没有「差不多」这种状态,只有过和不过。
看一眼真实链上的签名长什么样。
打开任意一个钱包,找到「签名消息」功能,签一条你自己写的短消息,把结果复制出来。
数一下字符:0x 加 130 个十六进制字符,正好 65 字节。前 64 个字符是 r,接着 64 个是 s,最后 2 个是 v。
和上一步 openssl 的输出对比:曲线是同一条,但以太坊用 Keccak-256 而不是 SHA-256,用固定的 65 字节而不是 DER 编码,而且在摘要之前还会给消息加一段固定前缀。**数学一样,编码和预处理不一样。**这个差异会让你在跨语言实现验签时踩至少一次坑。
把签名内容读一遍。
回到钱包,这次找一个真实 DApp 的登录签名,把弹窗里的全文读完。检查里面有没有:域名、随机数、过期时间、链的编号。
缺哪一项,就意味着这条签名在哪个维度上可以被重放。
AI Lab
分两步,第二步才是重点。
第一步:
用我指定的语言写一个函数,验证一条 Merkle 证明:
输入是 leaf、proof 数组、root,输出 true 或 false。
说明你对哈希函数、拼接顺序、叶子与内部节点区分这三点分别做了什么假设。
第二步:
现在你来当攻击者。给定你刚才写的这个实现,
构造一组输入,让它对一条根本不在树里的数据返回 true。
如果你认为构造不出来,说明是哪一行代码挡住了。第二步的价值在于:Merkle 验证有一个经典缺陷叫第二原像攻击——如果叶子和内部节点用同一种方式哈希,那么把一个内部节点当成叶子提交,加上更短的路径,同样能算到正确的根。防法是给叶子和内部节点加不同的前缀,或者强制它们长度不同。
模型很可能第一步写出有这个缺陷的版本,第二步又说「构造不出来」。这时候你把上面这段缺陷描述贴给它,让它重写。
你要练的不是写出这个函数,是知道该去问什么。
另外:让它给一个可以直接跑的例子时,如果它顺手编了一个空投合约地址或者一个 Merkle 根,去链上查一下。大概率查不到。
AI 说完之后,你必须自己验证
- 它写的哈希函数确实是这条链上用的那个,没有把 SHA-256 和 Keccak-256 混用
- 它对两个子节点的拼接顺序有明确规定,而不是隐含假设
- 它处理了叶子与内部节点的区分,否则一个内部节点可以被当作叶子来伪造
- 它给的库名和函数签名真实存在,你能在官方文档里查到同名的方法
- 十六进制字符串与字节数组的转换没有搞错,特别是有没有多算那两个 0x 字符
- 它没有把「证明验证通过」说成「这条数据一定是合法的」,根本身的可信度是另一件事
真实案例
ECDSA 每次签名需要一个一次性随机数。如果同一个私钥用同一个随机数签了两条不同的消息,任何人拿到这两条签名就能用初中代数解出私钥。
2010 年某游戏主机的代码签名系统把这个随机数写成了常量,私钥被公开计算出来,整套签名体系当场失效。2013 年某移动系统的随机数生成器存在缺陷,导致一批钱包生成了重复的随机数,地址里的币被陆续搬空。
这说明:**密码学算法没有被攻破,实现被攻破了。**你自己实现签名逻辑时,随机数这一块是最不该自己写的部分。
用户在一个仿冒站点点了「签名」,弹窗里是一串看不懂的十六进制。他签了。
那条消息实际上是一份挂单授权:把他钱包里的某个 NFT 以零价格卖给攻击者。签名本身完全合法,链上没有任何异常,钱包也没有任何理由拦截——用户确实同意了,只是他不知道自己同意了什么。
这类攻击在 2022 到 2023 年造成了大量损失。防御手段是结构化签名:把被签的内容按字段展开,让钱包能渲染成人话,同时把域名和链编号绑进去。T13 会完整讲这一层。
早期的交易格式里不包含链的编号。于是一条在测试网上发出的交易,原样广播到主网同样有效。
后来的规范把链编号写进被签的内容里解决了交易重放,但离线签名的消息仍然要靠应用自己处理。很多 DApp 的登录签名至今不带链编号和过期时间——意味着一条一年前抓到的签名今天还能登录。
把整份名单写进合约,成本高到不可接受;每次领取时由项目方签名放行,又要求项目方的服务一直在线。
用 Merkle 根的做法:链上只存一个 32 字节的根,名单挂在任意公开位置,用户自带路径来证明自己在名单里。项目方的服务器挂了也不影响领取。
这是 Merkle 结构在应用层最常见的一次落地,也是你最可能自己实现一遍的一次。
改一个变量
两份不同的数据算出同一个摘要,意味着一份 Merkle 证明可以指向两条不同的数据,一份合约的字节码校验可以被替换。
整套「用摘要代表数据」的做法会同时失效,而不是某一个应用出问题。这就是为什么链在选哈希函数上极度保守,宁可慢也不换新的。
所谓脑钱包。攻击者不需要破解算法,只需要把常见的诗句、歌词、密码字典挨个试一遍。
这类地址被扫空的平均时间是秒级。安全性不在算法里,在那个随机数的熵里。
签名变成一个永久有效的通行证。你的服务器日志、你的错误上报平台、任何一次抓包,都成了凭证泄露源。
更隐蔽的是:即使用户后来把这个地址的所有授权都撤销了,这条登录签名依然有效——因为它从来没上过链,链上没有任何东西可以撤销它。
曲线和算法都不同,地址就是公钥本身而不是公钥的摘要,所以从地址能直接拿到公钥。
一个直接后果:Solana 的交易里必须显式列出所有签名者,而以太坊是从签名反推的。T6 会讲这个差异背后的账户模型。
带走的问题
签名验证这件事本身不需要区块链,SSH 和 HTTPS 用了几十年。这一章里真正需要链的,是「验证的规则对所有人一样,而且没有人能改」——公钥恢复的逻辑写在协议里,不是写在某家公司的服务器上。
私钥丢了,风险百分之百由持有人承担,没有任何追索。这和传统金融的责任分配完全相反,也是为什么这门课后面所有涉及密钥的地方都要单独设计:多签、社交恢复、权限分层,本质上都是在把这个风险重新分配掉。
一个 AI Agent 拿到的如果是私钥,它的权限就是「这个地址的全部」,没有中间档位。要给它更小的权限,只能靠一份限定了额度、对象和时效的签名授权。先想清楚要授什么权,再想怎么签,顺序反了就会做出一个能被一句提示词掏空的 Agent。
本章自测
签名是用私钥对某一条特定消息算出来的一次性结果,从它反推私钥在计算上不可行,而且它只对那条消息有效。
私钥则是能对任何消息生成有效签名的能力本身。它不是一个凭证,它是凭证工厂。
不需要。以太坊的验签是从签名和消息里恢复出公钥,再算成地址,然后和用户声称的地址比对。
正因如此,交易里才不需要携带发送方地址——那是算出来的。这也解释了为什么一个从未交互过的地址,第一次来就能被正确验证。
密码学层面,一条签名永远有效,随时可以被重新提交。能不能被重放完全取决于被签的消息里写了什么。
防法只有一条:把限制条件写进消息本身——一次性随机数(服务端用完即作废)、域名、链编号、过期时间。少一项,就在那一个维度上可以被重放。
在不给出全部数据的情况下,证明某一条数据在这个集合里。
代价从「线性」降到「对数」:一百万条数据,证明只需要二十来个哈希。所有「链上存一个承诺、链下存全量数据」的设计,底下都是这个结构。
注意它不解决的问题:根本身是否可信。根写错了,所有证明都会正确地指向一份错误的名单。
没有标准答案,检查这几件事:
- 消息里是否包含服务端下发的一次性随机数,并且验证后立即作废。
- 是否包含域名,防止签名被另一个站点拿去用。
- 是否包含链的编号和过期时间。
- 消息是否是结构化的、钱包能渲染成人话的,而不是一串裸十六进制。
- 验签恢复出的地址,是否做了大小写无关的比较(校验和格式很容易在这里出错)。
- 是否明确区分了「签名登录」和「资产授权」——前者不该让用户产生「我批准了一笔转账」的错觉,后者绝不能藏在登录流程里。
能写出第 1 条和第 6 条,说明你已经理解了这一章真正的重点。
一句话带走
你永远不发送私钥,你只发送一个任何人都能验证的证明。