ERC-20 Permit 与 ECDSA 密码学原理
ERC-20 Permit 与 ECDSA 密码学原理
本文深入解析 EIP-712/EIP-2612 Permit 机制的工作原理,以及底层 ECDSA 椭圆曲线签名的密码学基础。
目录
- Permit 机制概述
- EIP-712 结构化签名
- Permit 完整代码实现 ⭐ 开发者必读
- ECDSA 密码学基础
- 椭圆曲线数学
- 签名与验证流程
- ecrecover 原理
- 安全性分析
- 实际应用场景
1. Permit 机制概述
1.1 传统 ERC-20 授权的问题
传统 ERC-20 代币转账需要两笔交易:
1 | 交易1: approve(spender, amount) ← 用户付 Gas |
痛点:
- 用户需要两次确认
- 需要支付两笔 Gas
- 用户体验差
1.2 Permit 解决方案 (EIP-2612)
Permit 允许用户通过离线签名完成授权:
1 | 步骤1: 用户离线签名授权信息 ← 免费,无 Gas |
优势:
- 单笔交易完成授权+操作
- 支持 Gasless 交易(元交易)
- 更好的用户体验
1.3 Permit 函数签名
1 | function permit( |
2. EIP-712 结构化签名
2.1 为什么需要 EIP-712
早期以太坊签名(eth_sign)只能签名原始字节:
1 | 用户在钱包看到: |
EIP-712 提供结构化、人类可读的签名格式:
flowchart LR
subgraph Wallet["🦊 钱包签名请求"]
direction TB
H["📋 授权请求"]
A["应用: Uniswap"]
B["授权给: 0x1234..."]
C["金额: 100 USDC"]
D["有效期至: 2024-01-01"]
end
style Wallet fill:#f9f9f9,stroke:#333,stroke-width:2px
EIP-712 的优势:
- 可读性:用户可以清楚看到授权的应用名称、被授权地址、金额和有效期
- 安全性:用户能够验证签名内容是否符合预期,避免被钓鱼攻击
- 标准化:钱包可以以统一的方式展示不同 DApp 的签名请求
2.2 EIP-712 签名结构
完整的 EIP-712 签名消息:
其中:
\x19\x01是 EIP-712 固定前缀domainSeparator标识签名的应用/合约structHash是实际授权内容的哈希
2.3 Domain Separator
Domain Separator 用于唯一标识签名的使用范围:
1 | bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode( |
防重放攻击:
| 字段 | 防止的攻击类型 |
|---|---|
name |
不同应用间签名重放 |
version |
合约升级后旧签名重放 |
chainId |
跨链签名重放 (ETH → BSC) |
verifyingContract |
同链不同合约间重放 |
2.4 Struct Hash
对于 Permit,structHash 的构造:
1 | bytes32 structHash = keccak256(abi.encode( |
3. Permit 完整代码实现
⭐ 开发者必读:本节展示 Permit 的完整实现,帮助你理解每一行代码的作用。
3.1 完整的 ERC20Permit 合约
1 | // SPDX-License-Identifier: MIT |
3.2 关键步骤图解
flowchart LR
A["1.检查deadline"] --> B["2.structHash"] --> C["3.digest"] --> D["4.ecrecover"] --> E["5.验证地址"] --> F["6._approve()"]
permit() 执行流程:
- 检查过期 →
require(block.timestamp <= deadline_)— 签名已过期则 revert - 构造 structHash → 将
(TYPEHASH, owner, spender, value, nonce, deadline)编码后哈希 - 构造 digest →
keccak256("\x19\x01" || DOMAIN_SEPARATOR || structHash)— 这就是用户签名的内容 - 恢复地址 →
ecrecover(digest, v, r, s)— 通过椭圆曲线数学从签名反推公钥 - 验证身份 →
require(recoveredAddress == owner_)— 只有正确私钥才能恢复出正确地址 - 执行授权 →
_approve(owner_, spender_, value_)— 与approve()效果相同
| 步骤 | 作用 | 安全保障 |
|---|---|---|
| 步骤 1 | 时效控制 | 防止使用过期签名 |
| 步骤 2-3 | 消息构造 | DOMAIN_SEPARATOR 确保签名只能在本合约使用 |
| 步骤 4-5 | 身份验证 | ecrecover 验证只有 owner 私钥才能产生有效签名 |
| 步骤 2 | 防重放 | nonce 递增确保每个签名只能使用一次 |
3.3 前端签名代码
1 | import { ethers } from "ethers"; |
3.4 完整交互流程
下图展示了 Permit 从签名到授权的完整流程:
sequenceDiagram
participant User as 用户(前端)
participant Wallet as 钱包
participant Contract as 合约(链上)
Note over User,Contract: Permit 完整流程
User->>Contract: 1. 获取 nonce
Contract-->>User: nonce = 5
Note over User,Wallet: 2. 构造 EIP-712 消息
User->>User: domain = {name, version, chainId, contract}
User->>User: message = {owner, spender, value, nonce=5, deadline}
Note over User,Wallet: 3. 钱包签名
User->>Wallet: 请求签名
Wallet->>Wallet: digest = hash(domain + message)
Wallet->>Wallet: (v, r, s) = sign(digest)
Wallet-->>User: 返回 (v, r, s)
User->>Contract: 4. 调用 permit(owner, spender, value, deadline, v, r, s)
Note over Contract: 5. 合约验证
Contract->>Contract: 检查 deadline
Contract->>Contract: 重建 digest
Contract->>Contract: ecrecover 恢复地址
Contract->>Contract: 验证地址 == owner
Contract->>Contract: nonce++(防重放)
Contract->>Contract: _approve()
Contract-->>User: 授权成功!
Note over User,Contract: 6. 后续操作(如 DEX 交易)
User->>Contract: transferFrom(owner, to, value)
流程要点说明:
| 步骤 | 执行者 | 说明 |
|---|---|---|
| 1. 获取 nonce | 前端 → 合约 | 每个地址的 nonce 唯一且递增,防止签名被重复使用 |
| 2. 构造消息 | 前端 | 按 EIP-712 格式组装 domain 和 message,确保格式与合约一致 |
| 3. 钱包签名 | 钱包 | 用户私钥对 digest 签名,产生 (v, r, s),此步骤无 Gas |
| 4. 提交签名 | 前端 → 合约 | 将签名和参数提交到链上,可由用户或中继者支付 Gas |
| 5. 合约验证 | 合约 | 重建 digest 并用 ecrecover 验证签名者身份 |
| 6. 后续操作 | 任何人 | 授权完成后,spender 可以调用 transferFrom |
3.5 为什么签名能证明身份?
这是理解 Permit 的关键。签名与消息之间存在密码学绑定关系:
flowchart TD
subgraph SignProcess["签名过程"]
A1["用户用私钥签名 digest"] --> A2["产生签名 (v, r, s)"]
end
subgraph VerifyProcess["验证过程"]
B1["合约用 ecrecover(digest, v, r, s)"] --> B2["反推出地址"]
B2 --> B3{"地址 == owner?"}
B3 -->|是| B4["✅ 验证成功"]
B3 -->|否| B5["❌ 验证失败"]
end
A2 --> B1
subgraph AttackScenarios["攻击场景分析"]
C1["篡改 value(金额)"] --> C2["digest 变了"]
C3["篡改 spender(授权给谁)"] --> C2
C4["篡改 deadline"] --> C2
C5["重放到其他合约"] --> C6["DOMAIN_SEPARATOR 不同"]
C6 --> C2
C7["重复使用签名"] --> C8["nonce 已递增"]
C8 --> C2
C2 --> C9["恢复出错误地址"]
C9 --> B5
end
攻击场景详解:
| 攻击方式 | 攻击者行为 | 防护机制 | 结果 |
|---|---|---|---|
| 篡改金额 | 修改 value 为更大数值 |
digest 包含 value | ecrecover 得到错误地址 → 失败 |
| 篡改授权对象 | 修改 spender 为自己 |
digest 包含 spender | ecrecover 得到错误地址 → 失败 |
| 延长有效期 | 修改 deadline |
digest 包含 deadline | ecrecover 得到错误地址 → 失败 |
| 跨合约重放 | 在其他合约使用签名 | DOMAIN_SEPARATOR 含合约地址 | digest 不同 → 失败 |
| 跨链重放 | 在其他链使用签名 | DOMAIN_SEPARATOR 含 chainId | digest 不同 → 失败 |
| 重复使用 | 多次提交同一签名 | nonce 递增机制 | 第二次 nonce 不匹配 → 失败 |
核心原理图示:
flowchart LR
A["签名 (v, r, s)"] <-->|"数学绑定"| B["digest"]
B --> C["任何篡改"]
C --> D["ecrecover 恢复出错误地址"]
D --> E["❌ 验证失败"]
为什么数学绑定是安全的?
ECDSA 签名的安全性基于椭圆曲线离散对数问题:
- 签名
(r, s)是通过私钥 和消息哈希 计算得出 - 公式:
- 如果 (digest)改变,用原签名恢复出的公钥就会不同
- 攻击者无法在不知道私钥的情况下为新的 digest 生成有效签名
💡 核心原理:签名 (v, r, s) 与 digest 是数学绑定的。任何篡改都会导致
ecrecover恢复出错误的地址。这就是密码学的魔法!
4. ECDSA 密码学基础
4.1 椭圆曲线密码学概述
以太坊使用 secp256k1 椭圆曲线,与比特币相同。
椭圆曲线方程:
其中 是一个大素数:
4.2 关键参数
secp256k1 曲线参数:
| 参数 | 含义 | 值 |
|---|---|---|
| 有限域大小 | ||
| 生成点 (Generator) | 固定的曲线上的点 | |
| 曲线阶 (点的个数) |
生成点 的坐标:
4.3 密钥对生成
私钥 :随机选择的 256 位整数,满足
公钥 :椭圆曲线上的点
这里 表示椭圆曲线上的点乘法( 与自身相加 次)。
安全性基础:椭圆曲线离散对数问题 (ECDLP)
已知 和 ,计算 在计算上不可行。
5. 椭圆曲线数学
5.1 点加法 (Point Addition)
给定曲线上两点 和 ,计算 :
当 时:
5.2 点倍乘 (Point Doubling)
当 时,计算 :
对于 secp256k1,,所以:
5.3 标量乘法 (Scalar Multiplication)
计算 (将 与自身相加 次):
使用 Double-and-Add 算法:
1 | 输入: 标量 k, 点 P |
时间复杂度: 次点运算
6. 签名与验证流程
6.1 ECDSA 签名算法
输入:
- 私钥
- 消息哈希 (256 位)
签名过程:
-
选择随机数 ,
-
计算曲线上的点:
-
计算 :
如果 ,返回步骤 1
-
计算 :
如果 ,返回步骤 1
-
签名为
v 的计算:
用于标识 坐标的奇偶性,帮助恢复公钥。
6.2 签名格式
以太坊签名格式:
block-beta
columns 3
block:sig["签名 (65 bytes)"]:3
r["r\n32 bytes"]
s["s\n32 bytes"]
v["v\n1 byte"]
end
| 组件 | 大小 | 说明 |
|---|---|---|
r |
32 bytes | 临时公钥点的 x 坐标 mod n |
s |
32 bytes | 签名标量值 |
v |
1 byte | 恢复标识符 (27 或 28) |
6.3 ECDSA 验证算法
输入:
- 公钥
- 消息哈希
- 签名
验证过程:
-
验证
-
计算:
-
计算曲线上的点:
-
验证:
为什么这样能验证?
展开 :
这正是签名时计算 所用的点!
7. ecrecover 原理
7.1 公钥恢复算法
ecrecover 是以太坊的预编译合约,从签名恢复公钥:
输入:消息哈希 ,签名
过程:
-
从 恢复曲线上的点
- 使用 确定 的奇偶性
- 解方程
-
计算公钥:
-
返回地址:
7.2 为什么可以恢复公钥?
从签名方程:
解出 (私钥对应的公钥系数):
由于 ,我们有:
所以:
7.3 Solidity 中的 ecrecover
1 | // 预编译合约,地址 0x01 |
8. 安全性分析
8.1 随机数 k 的重要性
致命错误:如果两次签名使用相同的 ,私钥将泄露!
假设两次签名 和 使用相同的 :
则:
一旦知道 ,可以计算私钥:
历史案例:2010 年 Sony PS3 私钥泄露,正是因为 ECDSA 签名使用了固定的 。
8.2 签名可锻性 (Malleability)
对于有效签名 , 也是有效签名。
解决方案:EIP-2 规定 必须在低半区:
1 | // OpenZeppelin ECDSA 库的检查 |
8.3 重放攻击防护
| 攻击类型 | 防护机制 |
|---|---|
| 跨链重放 | Domain Separator 包含 chainId |
| 跨合约重放 | Domain Separator 包含 verifyingContract |
| 同合约重放 | nonce 递增机制 |
| 过期签名 | deadline 时间戳检查 |
8.4 Permit 特有风险
钓鱼攻击:
- 恶意网站诱导用户签名
- 用户以为在签授权给 DEX
- 实际签名授权给攻击者地址
缓解措施:
- 钱包清晰展示签名内容
- 用户仔细核对 spender 地址
- 设置合理的 deadline
9. 实际应用场景
9.1 Permit 使用示例
前端签名:
1 | import { ethers } from "ethers"; |
合约调用:
1 | // 单笔交易完成授权 + 转账 |
9.2 Permit2 (Uniswap)
Permit2 是 Uniswap 开发的通用授权协议:
优势:
- 统一的授权管理
- 支持批量授权
- 支持签名转账
- 适用于所有 ERC-20(包括不支持 Permit 的)
1 | // Permit2 签名授权 |
9.3 Gasless 交易(元交易)
结合 Permit 实现无 Gas 交易:
1 | 1. 用户签名 Permit(离线) |
参考资料
EIP 标准文档
- EIP-712: Typed structured data hashing and signing
- EIP-2612: Permit Extension for ERC-20
- EIP-2: Homestead Hard-fork Changes (签名可锻性修复)
椭圆曲线与 secp256k1
- SEC 2: Recommended Elliptic Curve Domain Parameters - 官方标准文档
- Secp256k1 - Bitcoin Wiki - secp256k1 参数权威参考
- BSI TR-03111: Elliptic Curve Cryptography - 德国联邦信息安全办公室技术指南
ECDSA 算法详解
- ECDSA - Wikipedia
- The intuition behind elliptic curve digital signatures (ECDSA) - RareSkills - 公式推导详解
- ECDSA Explained: The Backbone of Digital Signature Security - Nervos
- What Is Elliptic Curve Digital Signature Algorithm? - Cyfrin
- How the ECDSA algorithm works - KaKaRoTo’s Blog
ecrecover 与公钥恢复
- ECRecover and Signature Verification in Ethereum - Coder’s Errand
- Can We Recover The Public Key from an ECDSA Signature? - Prof Bill Buchanan
- ECDSA: Sign / Verify - Examples - Practical Cryptography
Permit 与 EIP-712 实现
- Understanding Ethereum Off-Chain Signing, ECDSA, EIP-712 - Dev.to
- Ethereum’s ecrecover(), OpenZeppelin’s ECDSA, and web3’s sign() - Medium
- What is ecrecover in Solidity? - Solidity Developer
安全性研究
- Key Discovery in ECDSA: Understanding Implementation - Hacken - k 值重用攻击
- On the BUFF Security of ECDSA with Key Recovery - IACR ePrint
代码实现参考
附录:常用常量
secp256k1 参数
1 | p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F |
EIP-712 类型哈希
1 | // Domain type hash |





