T13 · Wallet Connection
登录一个 DApp,到底发生了什么?
- 练习的能力
- Builder
- 动手
- 实现一次签名登录,再实现一个撤销授权的入口,用测试钱包跑通两遍。
- AI Lab
- 让 AI 列出签名钓鱼的常见形态,检查你的界面是否把签名内容如实展示给用户。
一个现实问题
产品说:「加个钱包登录吧,跟微信登录差不多。」
你点开任意一个 DApp,右上角「Connect Wallet」,弹窗,确认,头像出来了。看上去确实差不多。
但把这个流程拆开看,你会发现它至少由三件完全不同的事情拼成:
- 浏览器插件把一个地址交给了页面——这件事没有经过任何验证,页面只是「被告知」了一个地址。
- 页面弹出一个签名请求,用户点了确认——这一步证明了什么?证明给谁看?
- 有些站点在登录之后立刻又弹一个窗,标题是「授权」,用户以为还是登录流程的一部分,也点了确认——这一下,可能把钱包里的全部某种代币交了出去。
三件事长得很像:都是弹窗,都是点确认。但它们的后果完全不同。第一件事什么都没发生,第二件事最坏情况是你的账号被别人用,第三件事最坏情况是你的钱没了。
用户分不清,是因为很多产品自己也没分清。 这一章要做的,就是把这三件事彻底拆开。
思想实验
把一把钥匙想成三样东西。
你入职一家公司,前台给了你一枚工牌。这枚工牌同时是:
- 一张门禁卡——刷一下,闸机开了。它只证明「你是员工」。
- 一枚签名章——盖在文件上,证明「这份文件我看过、我认可」。
- 一支能签空白支票的笔——签下去,财务就得付钱,而且签一次可以被反复兑付,直到你去把它挂失。
现实世界里这三样东西不会做成一个。门禁卡丢了补办一张,签名章丢了去公证处挂失,空白支票根本不会有人发给你。
Crypto 钱包的私钥把这三件事合并成了一个。同一枚私钥,既能刷门禁,也能盖章,也能签出那张可以被反复兑付的支票。区别不在于用了哪把钥匙,而在于你签的那张纸上写了什么。
而那张「可以被反复兑付的支票」在链上是有状态的:签出去以后会一直有效,直到你主动发一笔交易把它撤掉。关网页、清缓存、换电脑,它都还在。这是 Web2 的登录里完全不存在的一类东西。
于是问题变成:用户能不能看懂他正在签的那张纸?
你来决定
你要给自己的 DApp 做登录。四种做法,你选哪种?
观察结果
四个选项真正暴露的,是这样一件事:「连接」「认证」「授权」是三个不同的动作,只是在界面上都表现为一个弹窗。
| 连接 | 认证 | 授权 | |
|---|---|---|---|
| 用户做了什么 | 同意把地址告诉页面 | 签一条消息 | 签一笔交易 |
| 上链吗 | 不上 | 不上 | 上链,要花 Gas |
| 谁来验证 | 没人验证 | 你的后端 | 链上的合约 |
| 证明了什么 | 什么都没证明 | 证明持有私钥 | 授予了动用资金的权限 |
| 有期限吗 | 关页面就没了 | 你自己设 | 没有,直到主动撤销 |
| 出问题的代价 | 泄露一个公开地址 | 账号被冒用 | 资金被转走 |
两格值得单独拎出来。「没人验证」是最容易被跳过的一格:连接钱包拿到的地址谁都能伪造,它能用来展示,不能用来鉴权。「没有期限」是唯一一个「关掉网页也还在」的格子:所以授权必须有一个撤销入口,而且这个入口是你的产品的一部分,不是让用户自己去别处找。
还有一条这张表没画出来的:「不上链」不等于「没风险」。 一条链下签名本身就可以是一张授权令牌——后面「真实案例」里会看到,最凶的几起钓鱼全都不需要用户发交易。
建立模型
把登录拆成五段,每一段职责单一:
- Connect 拿到地址
- Authenticate 签名证明
- Session 发会话凭证
- Authorize 按需授权
- Revoke 随时撤销
被签的内容决定一切
签名请求分三类,风险差了几个数量级:
| 类型 | 用户在钱包里看到 | 风险 |
|---|---|---|
| 人类可读的消息 | 完整的文字,能读懂 | 低——前提是你没在里面藏东西 |
| 结构化数据 | 字段名与字段值 | 中——字段名可以起得很有迷惑性 |
| 一串原始哈希 | 一串十六进制 | 极高——用户无法知道自己签了什么 |
第三类叫盲签:用户看到一串没有意义的字符,确认之后可能发生任何事。如果你的产品要求用户盲签,你的产品设计有问题。
第二类更值得警惕,因为它看起来是安全的。一个字段叫 spender、值是一个地址,另一个字段叫 value、值是一个 78 位的数字——每个字段用户都「看见」了,但没有一个用户能从中读出「我把全部余额的动用权给了一个陌生地址,有效期一年」。
如实展示的标准不是「把原始数据显示出来」,而是「用户看完能说出后果」。 这两件事差得很远。
登录消息应该长什么样
用一段纯文本来表达,字段的语义比格式重要:
example.com 希望你用这个地址登录:
0x<你的地址>
点击签名即表示你同意登录 example.com。
这次签名不会发起交易,也不会授权任何资金操作。
Chain ID: <链 ID>
Nonce: <后端生成的一次性随机数>
Issued At: <签发时间>
Expiration Time: <过期时间>每个字段各自堵死一类攻击:域名堵死跨站重放,地址堵死拿别人的签名冒充,Chain ID 堵死跨链重放,Nonce 堵死同站重放(用完必须立刻在后端作废),过期时间限制签名被盗后的可用窗口。
那句「不会发起交易,也不会授权任何资金操作」不是客套话,它是你对用户的承诺。用户每见一次这句话并且事后证实为真,他对弹窗的信任就积累一分;你在登录里偷偷塞一次授权,透支的是整个行业的信任。
会话的生命周期
签名验过之后,发一个普通会话凭证就行,和 Web2 完全一样。但有三个 Crypto 特有的失效条件:
type WalletSession = {
address: string; // 认证通过的地址
chainId: number; // 认证时所在的链
issuedAt: number;
expiresAt: number;
};
// 必须立刻销毁会话的三种情况:
// 1. 钱包切换了账户,当前地址不再等于 session.address
// 2. 钱包切换了链,业务要求单链时 chainId 不匹配
// 3. 钱包断开连接第一条最容易漏。用户在钱包里切到另一个账户,页面上的会话还是旧地址——于是他用 B 账户的界面,操作着 A 账户的数据。处理方式很简单:监听账户变更事件,一旦地址变了就立刻销毁会话并要求重新认证,而不是「静默切换」。
授权要按需、按额、可撤销
授权是一笔上链交易,设计它的时候只有三个问题:
- 什么时候弹? 在用户第一次真的要用到那个功能的时候,不是在登录时。
- 额度给多少? 给这次操作需要的量,不是无限。无限额度省下的那一次点击,换来的是永久的敞口。
- 怎么撤? 你的产品里必须有一个页面,能列出用户在你这里授过的权,并且一键撤销。
第 3 条是这一章 Lab 的一半。很多团队觉得「用户可以去别的工具撤销」,但你的产品制造的风险,应该由你的产品提供出口。
它叫什么
把「不同钱包的不同接入方式」统一成一套接口的那一层:浏览器插件、移动端深链、二维码配对、智能合约账户,对上层暴露同样的「请求账户 / 请求签名 / 发送交易」。
它的价值不在于少写代码,而在于让你的业务逻辑不依赖某一个钱包的实现细节。换钱包不该改状态机。
一套把登录消息标准化的约定:规定了域名、地址、链 ID、Nonce、签发时间、过期时间这些字段的格式与顺序。
它的核心贡献不是格式,是让钱包能够认出「这是一条登录消息」并如实地渲染它。 自己发明一套格式,钱包只能当成普通文本显示,用户的辨识成本就回到了零。
认证成功之后签发的凭证,代表「这个地址在这段时间内已经证明过自己」。
它是普通的 Web 会话,没有任何特殊之处。特殊的是它的销毁条件:账户切换、链切换、钱包断开,三者任一发生都要立刻失效。
一笔上链交易,允许某个地址在某个额度内动用你的某种资产。
三个必须记住的性质:它是持久的(关页面不会消失)、它是可被反复使用的(额度用完之前一直有效)、它和登录毫无关系(登录不需要授权)。
把已经给出的授权额度改回零。它同样是一笔上链交易,要花 Gas。
撤销的关键认知:撤销只能阻止未来的动用,不能追回已经被转走的资产。 发现被钓之后第一件事是撤销剩余授权,但已经出去的钱回不来。
用户在钱包里看到的只是一串无法解读的数据,无法判断签署后果。
盲签不是用户的问题,是上游没有把数据变成人话。要求用户盲签的产品,等于要求用户信任你——而 Crypto 的全部意义就在于减少这类信任。
动手
全程使用测试网,钱包里只放测试币,不要用任何有真实资产的地址。 撤销授权那一步会发一笔真实交易,在测试网上做。
连接,并且承认它什么都没证明。
请求账户,拿到地址和链 ID。把它存在一个叫 connection 的状态里,不要叫 user。
命名在这里是有意义的:只要它叫 user,迟早会有人把它传给后端当身份用。
向后端要一个 Nonce。
后端生成一个一次性随机数,和地址绑定,存起来,设一个短的有效期(几分钟足够)。
注意:Nonce 必须由后端生成。前端生成的随机数不能防重放,因为攻击者也能生成。
构造登录消息并请求签名。
按上文那份模板拼一条纯文本消息,把域名、地址、链 ID、Nonce、签发时间、过期时间都填进去。
拼好之后自己先在钱包里看一眼:弹窗里的文字和你拼的那一条要一模一样,没有被截断、没有变成乱码、没有变成十六进制。看不清的,用户更看不清。
后端验签,然后作废 Nonce。
后端做五件事,缺一不可:
- 从签名还原出地址,比对消息里的地址
- 校验域名等于自己的域名
- 校验 Nonce 存在、未被使用过——校验完立刻删掉
- 校验没有过期
- 校验链 ID 符合预期
通过之后签发会话 Cookie,设成 HttpOnly、Secure、SameSite。
第 3 步的「立刻删掉」要用原子操作,否则两个并发请求会同时通过。
接上失效逻辑。
监听账户变更和链变更。地址一变,立刻清掉前端状态并调用后端登出。
自己测一遍:登录成功后在钱包里切到另一个账户,看页面是不是立刻退出登录。如果页面还显示着旧地址的数据,这个 bug 现在就在你的代码里。
做一个授权面板。
查询 allowance 并列出当前用户在你这个合约上的授权。展示时把数字翻译成人话——额度是天文数字就直接显示「无限额度」并标红,不要显示那一长串数字。
然后加一个撤销按钮:发一笔把额度改成 0 的交易,等它确认,刷新列表。
跑两遍,并做一次攻击。
第一遍:正常登录 → 授权一个有限额度 → 撤销 → 确认额度归零。
第二遍:把第一遍抓到的签名,换一个域名的请求重新提交给后端,它必须被拒绝;再把同一个签名对着同一个后端提交两遍,第二遍也必须被拒绝。前者验证域名校验,后者验证 Nonce 真的作废了。
做完这六步,你就有了一套可以直接用在生产里的登录流程,而且你亲手验证过它的两条防线。
AI Lab
分两步,第二步才是重点:
第一步:
列出 Crypto 钱包签名钓鱼的常见形态。对每一种说明:
- 攻击者让用户签的是什么类型的数据
- 用户在钱包弹窗里实际会看到什么
- 签完之后攻击者能做什么、需不需要用户再做别的操作
- 从用户的视角,有没有任何可识别的信号
第二步:
这是我的登录与授权界面的文案和弹窗内容(贴上你的实际内容)。
按上面每一种形态逐条检查:我的界面在这个点上,是如实展示了,
还是有可能让用户误判?给出具体的改法,不要给笼统建议。第一步模型答得会不错,因为这是一个枚举题。第二步才是你真正要的东西:把它的清单当成检查表,逼自己对每一条给出「我的界面在这里显示什么」的具体答案。
答不上来的那几条,就是你的产品正在制造的风险。
一个高频的产出偏差要提前知道:模型很容易只讲「假网站、假域名」这类钓鱼,而把真域名上的误导性签名讲得很轻。后者才是难防的——网址是对的、站点是真的,只是那个弹窗里签的东西和界面上写的不是一回事。追问它这一类。
AI 说完之后,你必须自己验证
- 它列出的每一种形态,你都能说清「用户在钱包里具体会看到什么」,而不只是一句概括
- 它有没有提到不需要发交易、只靠一条链下签名就能转走资产的那一类——这一类最危险,也最容易被漏掉
- 它给的签名数据格式与字段名,你在真实钱包里弹一次,确认字段名和它说的一致
- 它有没有把「登录签名」和「授权签名」混为一谈
- 它建议的防御措施里,有没有「加一句风险提示」这类只靠文案、不改变信息呈现的假防御
- 拿它的清单逐条比对你自己的界面:每一条你都要给出「我的界面在这里显示什么」的具体答案
真实案例
很多产品为了「少点一次」,在用户第一次进入时就请求无限额度授权。
用户当时什么都没做,钱也没动,所以不会觉得有什么不对。但从那一刻起,只要这个合约出问题,他的这种代币就是暴露的——哪怕他后来一次都没用过这个功能。多年来的多起事故都是同一个形状:合约被攻破,损失名单上大量是「早就不用了但从没撤销过」的地址。
教训:授权在用到的那一刻再要,额度按需给。
有一类授权可以通过一条链下签名完成,不需要用户发起任何交易,因此也不消耗 Gas。
它的可怕之处在于:用户被教育成「签名是安全的,交易才危险」。看到「这只是一个签名,免费的」,戒心直接降到零。签完,攻击者拿着这条签名去链上执行,资产就走了。这类事故的受害者常常说同一句话:「我没有确认任何交易。」
教训:「不上链」和「没风险」是两件事。 界面必须替用户把签名的后果讲清楚。
NFT 交易类的协议大量使用离线订单:用户签一条结构化数据,表示「我愿意用某个价格卖出某件资产」。
攻击者把这条数据伪装在一个看起来像登录的流程里。用户看到的是一堆字段名,读不出含义,点了确认——实际上签出了一张「零价出售」的订单。
教训:结构化签名的字段名可以被起得很有迷惑性。可读不等于可理解,如实展示的标准是「用户看完能说出后果」。
一些操作在硬件钱包的小屏幕上只能显示一串十六进制。用户没有任何办法判断自己在签什么,只能相信电脑屏幕上的界面——而电脑屏幕正是被攻破的那一端。硬件钱包保护的是私钥不被导出,它保护不了「你签了一个你不理解的东西」。
教训:设计协议时,能不能被清楚地展示,是一个需求,不是锦上添花。
改一个变量
如果你没有监听账户变更,页面会进入一个最糟糕的状态:界面显示着 A 账户的数据,而钱包会用 B 账户去签名。 用户以为自己在操作 A,实际操作的是 B,轻则数据错乱,重则把钱转错地方。
正确做法只有一个:地址一变,立刻销毁会话,回到未登录状态。不要试图「自动切换」——那意味着你在用户没有重新证明身份的情况下,给了他另一个身份的权限。
合约钱包没有传统意义上的私钥签名,它的「签名」由合约自己定义和验证。你那套「从签名还原地址再比对」的验签逻辑会直接失败。
这类钱包的验签要走合约提供的验证接口:把消息哈希和签名交给合约,由它回答「这个签名对我有效吗」。
更深的一层影响:合约钱包的控制者是可以变的。今天通过验证的那个人,明天可能已经不是账户的主人了。所以会话的有效期要短,重要操作要重新验证。T27 会展开讲账户抽象这条线。
签名一旦泄露——被缓存、被日志记下、被中间人截获——就是一张永久有效的通行证。而且它比密码泄露更难察觉:签名不会被用户改掉,也没有「异地登录提醒」这种东西。
过期时间的作用不是防止泄露,而是限制泄露之后的损失窗口。这两者的区别,就是安全设计里「预防」和「限制爆炸半径」的区别。
那么你的整套认证就不存在了。任何人往接口里填任意地址,就能读取那个地址的私有数据、以它的名义发起操作。这个错误之所以常见,是因为开发时前端确实拿得到地址,传给后端看起来太自然了。
一条可以贴在代码评审清单上的规则:后端的任何一个受保护接口,地址都必须来自会话,不能来自请求参数。 请求参数里出现地址的地方,都要问一句「这是在指定查询对象,还是在声明身份」。
带走的问题
用户是谁?在这一章里,这个问题有一个很具体的技术含义:你的后端凭什么认为请求来自这个地址的主人。 如果答案是「前端告诉我的」,那你其实不知道用户是谁。
谁承担风险?授权一旦给出去,风险就完全落在用户身上,而且是持续的、他察觉不到的。你少弹一次窗省下的那点体验,是用用户的长期敞口换来的。
哪些决策必须保留人工介入?签名确认本身就是这个课程里最原始的一道 Human-in-the-loop。它之所以常常失效,不是因为用户不看,而是因为弹窗里的内容让人看不懂。让人能看懂,是产品的责任。T32 会把这条线延伸到 Agent 的审批设计。
本章自测
因为地址是公开信息,谁都能说出一个地址。连接这个动作没有产生任何可验证的证据。前端用它做展示完全可以——反正展示的也是公开数据;但只要有一个接口需要「只有这个地址的主人才能访问」,就必须有签名验证。
判断标准很简单:这个数据如果被别人看到会不会有问题? 会,就必须验签。
签名是证明:证明你持有某个地址的私钥。它不上链、不花钱、本身不改变任何状态。
授权是许可:允许某个地址动用你的资产。它上链、花 Gas、持久有效、必须主动撤销。
但有一个重要的例外要记住:某些签名本身就是授权——它不上链,却能被别人拿去链上执行。所以更准确的判断标准不是「上不上链」,而是「这条数据签出去之后,别人能用它做什么」。
域名防跨站重放:用户在别的站点签的登录消息,拿到你这里不能用。没有它,你的安全性等于所有站点里最差的那一个。
Nonce 防同站重放:签名是确定的,同一条消息的签名永远一样。没有一次性随机数,一条签名可以被无限次使用。
两者都必须在后端校验。前端的校验只能防止误操作,防不了攻击——攻击者根本不用你的前端。
问题:额度一旦给出就是永久的,且是全部余额的敞口。合约将来出任何问题,所有授权过的用户一起遭殃,包括那些早就不用了的。
产品这么做的原因很实在:按需授权意味着用户每次操作都要多一笔交易、多一次等待、多一笔 Gas。转化率确实会掉。
合理的折中:给一个足够这次用、但不是无限的额度,并且在产品里提供清晰的撤销入口。把撤销做得足够容易,比把授权做得足够大更重要。
没有标准答案,检查这几件事:
- 用户看完你的界面,能不能用一句话说出「我签了之后会发生什么」?说不出来,就是没展示清楚。
- 你有没有把大数字翻译成人话?一串 78 位的数字要显示成「无限额度」并标红。
- 你有没有把对方地址的含义讲清楚?只显示
0x开头的一串,用户判断不了。 - 这次签名是身份证明还是资金授权,界面上有没有用不同的视觉语言区分?两种弹窗长得一样,是很多事故的起点。
- 如果用户拒绝签名,你的界面是显示一个错误弹窗,还是安静地退回去?拒签不是错误。
第 5 条看起来是细节,但它决定了用户会不会养成「随手确认」的习惯。
一句话带走
签名是身份证明,不是授权;授权必须单独设计并且可以撤销。