Crypto OS
Technical Crypto OS第三阶段 · Full-stack Onchain Application

T13 · Wallet Connection

登录一个 DApp,到底发生了什么?

练习的能力
Builder
动手
实现一次签名登录,再实现一个撤销授权的入口,用测试钱包跑通两遍。
AI Lab
让 AI 列出签名钓鱼的常见形态,检查你的界面是否把签名内容如实展示给用户。

一个现实问题

产品说:「加个钱包登录吧,跟微信登录差不多。」

你点开任意一个 DApp,右上角「Connect Wallet」,弹窗,确认,头像出来了。看上去确实差不多。

但把这个流程拆开看,你会发现它至少由三件完全不同的事情拼成:

  • 浏览器插件把一个地址交给了页面——这件事没有经过任何验证,页面只是「被告知」了一个地址。
  • 页面弹出一个签名请求,用户点了确认——这一步证明了什么?证明给谁看?
  • 有些站点在登录之后立刻又弹一个窗,标题是「授权」,用户以为还是登录流程的一部分,也点了确认——这一下,可能把钱包里的全部某种代币交了出去。

三件事长得很像:都是弹窗,都是点确认。但它们的后果完全不同。第一件事什么都没发生,第二件事最坏情况是你的账号被别人用,第三件事最坏情况是你的钱没了。

用户分不清,是因为很多产品自己也没分清。 这一章要做的,就是把这三件事彻底拆开。

思想实验

把一把钥匙想成三样东西。

你入职一家公司,前台给了你一枚工牌。这枚工牌同时是:

  1. 一张门禁卡——刷一下,闸机开了。它只证明「你是员工」。
  2. 一枚签名章——盖在文件上,证明「这份文件我看过、我认可」。
  3. 一支能签空白支票的笔——签下去,财务就得付钱,而且签一次可以被反复兑付,直到你去把它挂失

现实世界里这三样东西不会做成一个。门禁卡丢了补办一张,签名章丢了去公证处挂失,空白支票根本不会有人发给你。

Crypto 钱包的私钥把这三件事合并成了一个。同一枚私钥,既能刷门禁,也能盖章,也能签出那张可以被反复兑付的支票。区别不在于用了哪把钥匙,而在于你签的那张纸上写了什么

而那张「可以被反复兑付的支票」在链上是有状态的:签出去以后会一直有效,直到你主动发一笔交易把它撤掉。关网页、清缓存、换电脑,它都还在。这是 Web2 的登录里完全不存在的一类东西。

于是问题变成:用户能不能看懂他正在签的那张纸?

你来决定

你要给自己的 DApp 做登录。四种做法,你选哪种?

观察结果

四个选项真正暴露的,是这样一件事:「连接」「认证」「授权」是三个不同的动作,只是在界面上都表现为一个弹窗。

连接认证授权
用户做了什么同意把地址告诉页面签一条消息签一笔交易
上链吗不上不上上链,要花 Gas
谁来验证没人验证你的后端链上的合约
证明了什么什么都没证明证明持有私钥授予了动用资金的权限
有期限吗关页面就没了你自己设没有,直到主动撤销
出问题的代价泄露一个公开地址账号被冒用资金被转走

两格值得单独拎出来。「没人验证」是最容易被跳过的一格:连接钱包拿到的地址谁都能伪造,它能用来展示,不能用来鉴权。「没有期限」是唯一一个「关掉网页也还在」的格子:所以授权必须有一个撤销入口,而且这个入口是你的产品的一部分,不是让用户自己去别处找。

还有一条这张表没画出来的:「不上链」不等于「没风险」。 一条链下签名本身就可以是一张授权令牌——后面「真实案例」里会看到,最凶的几起钓鱼全都不需要用户发交易。

建立模型

把登录拆成五段,每一段职责单一:

  1. Connect 拿到地址
  2. Authenticate 签名证明
  3. Session 发会话凭证
  4. Authorize 按需授权
  5. 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 账户的数据。处理方式很简单:监听账户变更事件,一旦地址变了就立刻销毁会话并要求重新认证,而不是「静默切换」。

授权要按需、按额、可撤销

授权是一笔上链交易,设计它的时候只有三个问题:

  1. 什么时候弹? 在用户第一次真的要用到那个功能的时候,不是在登录时。
  2. 额度给多少? 给这次操作需要的量,不是无限。无限额度省下的那一次点击,换来的是永久的敞口。
  3. 怎么撤? 你的产品里必须有一个页面,能列出用户在你这里授过的权,并且一键撤销。

第 3 条是这一章 Lab 的一半。很多团队觉得「用户可以去别的工具撤销」,但你的产品制造的风险,应该由你的产品提供出口

它叫什么

Wallet Adapter钱包适配器

把「不同钱包的不同接入方式」统一成一套接口的那一层:浏览器插件、移动端深链、二维码配对、智能合约账户,对上层暴露同样的「请求账户 / 请求签名 / 发送交易」。

它的价值不在于少写代码,而在于让你的业务逻辑不依赖某一个钱包的实现细节。换钱包不该改状态机。

SIWESign-In with Ethereum

一套把登录消息标准化的约定:规定了域名、地址、链 ID、Nonce、签发时间、过期时间这些字段的格式与顺序。

它的核心贡献不是格式,是让钱包能够认出「这是一条登录消息」并如实地渲染它。 自己发明一套格式,钱包只能当成普通文本显示,用户的辨识成本就回到了零。

Session会话

认证成功之后签发的凭证,代表「这个地址在这段时间内已经证明过自己」。

它是普通的 Web 会话,没有任何特殊之处。特殊的是它的销毁条件:账户切换、链切换、钱包断开,三者任一发生都要立刻失效。

Approve授权

一笔上链交易,允许某个地址在某个额度内动用你的某种资产。

三个必须记住的性质:它是持久的(关页面不会消失)、它是可被反复使用的(额度用完之前一直有效)、它和登录毫无关系(登录不需要授权)。

Revoke撤销

把已经给出的授权额度改回零。它同样是一笔上链交易,要花 Gas。

撤销的关键认知:撤销只能阻止未来的动用,不能追回已经被转走的资产。 发现被钓之后第一件事是撤销剩余授权,但已经出去的钱回不来。

Blind Signing盲签

用户在钱包里看到的只是一串无法解读的数据,无法判断签署后果。

盲签不是用户的问题,是上游没有把数据变成人话。要求用户盲签的产品,等于要求用户信任你——而 Crypto 的全部意义就在于减少这类信任。

动手

动手实现一次签名登录,再实现一个撤销授权的入口任意前端框架 + 一个后端 + 测试钱包0 元,全程测试网

全程使用测试网,钱包里只放测试币,不要用任何有真实资产的地址。 撤销授权那一步会发一笔真实交易,在测试网上做。

连接,并且承认它什么都没证明。

请求账户,拿到地址和链 ID。把它存在一个叫 connection 的状态里,不要叫 user

命名在这里是有意义的:只要它叫 user,迟早会有人把它传给后端当身份用。

向后端要一个 Nonce。

后端生成一个一次性随机数,和地址绑定,存起来,设一个短的有效期(几分钟足够)。

注意:Nonce 必须由后端生成。前端生成的随机数不能防重放,因为攻击者也能生成。

构造登录消息并请求签名。

按上文那份模板拼一条纯文本消息,把域名、地址、链 ID、Nonce、签发时间、过期时间都填进去。

拼好之后自己先在钱包里看一眼:弹窗里的文字和你拼的那一条要一模一样,没有被截断、没有变成乱码、没有变成十六进制。看不清的,用户更看不清。

后端验签,然后作废 Nonce。

后端做五件事,缺一不可:

  1. 从签名还原出地址,比对消息里的地址
  2. 校验域名等于自己的域名
  3. 校验 Nonce 存在、未被使用过——校验完立刻删掉
  4. 校验没有过期
  5. 校验链 ID 符合预期

通过之后签发会话 Cookie,设成 HttpOnly、Secure、SameSite。

第 3 步的「立刻删掉」要用原子操作,否则两个并发请求会同时通过。

接上失效逻辑。

监听账户变更和链变更。地址一变,立刻清掉前端状态并调用后端登出

自己测一遍:登录成功后在钱包里切到另一个账户,看页面是不是立刻退出登录。如果页面还显示着旧地址的数据,这个 bug 现在就在你的代码里。

做一个授权面板。

查询 allowance 并列出当前用户在你这个合约上的授权。展示时把数字翻译成人话——额度是天文数字就直接显示「无限额度」并标红,不要显示那一长串数字。

然后加一个撤销按钮:发一笔把额度改成 0 的交易,等它确认,刷新列表。

跑两遍,并做一次攻击。

第一遍:正常登录 → 授权一个有限额度 → 撤销 → 确认额度归零。

第二遍:把第一遍抓到的签名,换一个域名的请求重新提交给后端,它必须被拒绝;再把同一个签名对着同一个后端提交两遍,第二遍也必须被拒绝。前者验证域名校验,后者验证 Nonce 真的作废了。

做完这六步,你就有了一套可以直接用在生产里的登录流程,而且你亲手验证过它的两条防线。

AI Lab

AI Lab让 AI 列出签名钓鱼的常见形态,再逐条检查你的界面Level 2 · AI Copilot

分两步,第二步才是重点:

第一步:
列出 Crypto 钱包签名钓鱼的常见形态。对每一种说明:
- 攻击者让用户签的是什么类型的数据
- 用户在钱包弹窗里实际会看到什么
- 签完之后攻击者能做什么、需不需要用户再做别的操作
- 从用户的视角,有没有任何可识别的信号

第二步:
这是我的登录与授权界面的文案和弹窗内容(贴上你的实际内容)。
按上面每一种形态逐条检查:我的界面在这个点上,是如实展示了,
还是有可能让用户误判?给出具体的改法,不要给笼统建议。

第一步模型答得会不错,因为这是一个枚举题。第二步才是你真正要的东西:把它的清单当成检查表,逼自己对每一条给出「我的界面在这里显示什么」的具体答案。

答不上来的那几条,就是你的产品正在制造的风险。

一个高频的产出偏差要提前知道:模型很容易只讲「假网站、假域名」这类钓鱼,而把真域名上的误导性签名讲得很轻。后者才是难防的——网址是对的、站点是真的,只是那个弹窗里签的东西和界面上写的不是一回事。追问它这一类。

AI 说完之后,你必须自己验证

  • 它列出的每一种形态,你都能说清「用户在钱包里具体会看到什么」,而不只是一句概括
  • 它有没有提到不需要发交易、只靠一条链下签名就能转走资产的那一类——这一类最危险,也最容易被漏掉
  • 它给的签名数据格式与字段名,你在真实钱包里弹一次,确认字段名和它说的一致
  • 它有没有把「登录签名」和「授权签名」混为一谈
  • 它建议的防御措施里,有没有「加一句风险提示」这类只靠文案、不改变信息呈现的假防御
  • 拿它的清单逐条比对你自己的界面:每一条你都要给出「我的界面在这里显示什么」的具体答案

真实案例

登录时的无限额度授权

很多产品为了「少点一次」,在用户第一次进入时就请求无限额度授权。

用户当时什么都没做,钱也没动,所以不会觉得有什么不对。但从那一刻起,只要这个合约出问题,他的这种代币就是暴露的——哪怕他后来一次都没用过这个功能。多年来的多起事故都是同一个形状:合约被攻破,损失名单上大量是「早就不用了但从没撤销过」的地址。

教训:授权在用到的那一刻再要,额度按需给。

不需要发交易的签名钓鱼

有一类授权可以通过一条链下签名完成,不需要用户发起任何交易,因此也不消耗 Gas。

它的可怕之处在于:用户被教育成「签名是安全的,交易才危险」。看到「这只是一个签名,免费的」,戒心直接降到零。签完,攻击者拿着这条签名去链上执行,资产就走了。这类事故的受害者常常说同一句话:「我没有确认任何交易。」

教训:「不上链」和「没风险」是两件事。 界面必须替用户把签名的后果讲清楚。

签的是订单,不是登录

NFT 交易类的协议大量使用离线订单:用户签一条结构化数据,表示「我愿意用某个价格卖出某件资产」。

攻击者把这条数据伪装在一个看起来像登录的流程里。用户看到的是一堆字段名,读不出含义,点了确认——实际上签出了一张「零价出售」的订单。

教训:结构化签名的字段名可以被起得很有迷惑性。可读不等于可理解,如实展示的标准是「用户看完能说出后果」。

硬件钱包上的一串哈希

一些操作在硬件钱包的小屏幕上只能显示一串十六进制。用户没有任何办法判断自己在签什么,只能相信电脑屏幕上的界面——而电脑屏幕正是被攻破的那一端。硬件钱包保护的是私钥不被导出,它保护不了「你签了一个你不理解的东西」。

教训:设计协议时,能不能被清楚地展示,是一个需求,不是锦上添花。

改一个变量

如果用户中途在钱包里切换了账户

如果你没有监听账户变更,页面会进入一个最糟糕的状态:界面显示着 A 账户的数据,而钱包会用 B 账户去签名。 用户以为自己在操作 A,实际操作的是 B,轻则数据错乱,重则把钱转错地方。

正确做法只有一个:地址一变,立刻销毁会话,回到未登录状态。不要试图「自动切换」——那意味着你在用户没有重新证明身份的情况下,给了他另一个身份的权限。

如果用户用的是智能合约钱包

合约钱包没有传统意义上的私钥签名,它的「签名」由合约自己定义和验证。你那套「从签名还原地址再比对」的验签逻辑会直接失败。

这类钱包的验签要走合约提供的验证接口:把消息哈希和签名交给合约,由它回答「这个签名对我有效吗」。

更深的一层影响:合约钱包的控制者是可以变的。今天通过验证的那个人,明天可能已经不是账户的主人了。所以会话的有效期要短,重要操作要重新验证。T27 会展开讲账户抽象这条线。

如果登录签名不设过期时间

签名一旦泄露——被缓存、被日志记下、被中间人截获——就是一张永久有效的通行证。而且它比密码泄露更难察觉:签名不会被用户改掉,也没有「异地登录提醒」这种东西。

过期时间的作用不是防止泄露,而是限制泄露之后的损失窗口。这两者的区别,就是安全设计里「预防」和「限制爆炸半径」的区别。

如果你的后端直接信任前端传来的地址

那么你的整套认证就不存在了。任何人往接口里填任意地址,就能读取那个地址的私有数据、以它的名义发起操作。这个错误之所以常见,是因为开发时前端确实拿得到地址,传给后端看起来太自然了

一条可以贴在代码评审清单上的规则:后端的任何一个受保护接口,地址都必须来自会话,不能来自请求参数。 请求参数里出现地址的地方,都要问一句「这是在指定查询对象,还是在声明身份」。

带走的问题

4
用户是谁?

用户是谁?在这一章里,这个问题有一个很具体的技术含义:你的后端凭什么认为请求来自这个地址的主人。 如果答案是「前端告诉我的」,那你其实不知道用户是谁。

9
谁承担风险?

谁承担风险?授权一旦给出去,风险就完全落在用户身上,而且是持续的、他察觉不到的。你少弹一次窗省下的那点体验,是用用户的长期敞口换来的。

18
哪些决策必须保留 Human-in-the-loop?

哪些决策必须保留人工介入?签名确认本身就是这个课程里最原始的一道 Human-in-the-loop。它之所以常常失效,不是因为用户不看,而是因为弹窗里的内容让人看不懂。让人能看懂,是产品的责任。T32 会把这条线延伸到 Agent 的审批设计。

本章自测

一句话带走

签名是身份证明,不是授权;授权必须单独设计并且可以撤销。

做完这一章的动手环节了?勾上它查看全部进度

本页目录