通俗拆解交易签名、消息签名、重放攻击、EIP 设计思路,并给出现成代码示例。
1. 以太坊签名核心价值
签名在链上解决三大难题:
- 身份认证:只有私钥持有人能生成合法签名。
- 不可抵赖:签名一旦上链,任何人都可独立验证其有效性。
- 数据完整:任何字节变动都会导致验证失败。
用一句话概括:链上签名如同现实世界合同上的手写签名,但公开透明、零信任、防篡改、不可伪冒。
2. 签名与验证的流程
2.1 核心算法:ECDSA
以太坊采用椭圆曲线数字签名算法(ECDSA,前文已删)。
摘要版流程:
- 签名:消息 + 私钥 → r、s、v
- 验证:消息 + 签名 → 恢复公钥 → 校验地址是否匹配
2.2 关键参数说明
| 参数 | 作用 |
|---|---|
| r、s | 构成签名的主体,同时保证随机性 |
| v | 额外 1 Byte 的恢复标志,通过它可降低 gas(以太坊 r/s 之外额外引入) |
| ChainId | 通过 EIP-155 被编码进 v,防范跨链重放 |
3. 交易签名实战
3.1 组成字段(RAW Transaction Fields)
nonce: 账户已发出交易数,用于顺序 & 重放保护gasPrice/gasLimit: 计算手续费to: 空代表新部署合约value: 转账金额data: ABI 编码的函数调用或合约字节码chainId: 主网为 1,避免签名在其他链被复用
const tx = {
nonce: 5,
gasPrice: 1e12, // 1 Gwei
gasLimit: 3000000, // 上限
to: null, // 空:创建合约
value: 0,
data: "0x6080...abcd",// bytecode
chainId: 1
};3.2 RLP + Keccak256
- 按顺序 RLP 编码 原始字段。
- 对第 1 步结果 Keccak256 哈希 得到 32 字节消息摘要。
- ECDSA 签名 → 得到 v、r、s。
- 再次 RLP 编码 字段 + vrs 得到最终交易序列化串。
4. 超重击:重放攻击与防御
| 攻击 | 防御方案 |
|---|---|
| 同链重放 | 配置 唯一 nonce 与 已用签名记录 mapping |
| 跨链重放 | ChainId 写入签名并对接收链做校验 |
| 合约复用重放 | 在签名中加入 合约地址 domainSeparator |
5. 消息签名:链下验证的利器
链下签名不直接落区块,而是传给合约,合约再调用 ecrecover 做二次验证。最常见的三大场景:
- 白名单空投
- 代理授权(gasless)
- Permit(EIP-712)省去 approve 交易
5.1 通用消息格式 & 实验
MetaMask 的 personal_sign 会在消息前加固定前缀 "\x19Ethereum Signed Message:\n",这样浏览器甚至能在确认框里显示“人类可见”内容,有效防范钓鱼。
NFT 白名单举例
合约片段:
using ECDSA for bytes32;
/// @dev 链下项目方签名映射到白名单地址
function mintNFT(address account, uint256 tokenId, bytes calldata signature) external {
bytes32 digest = keccak256(abi.encodePacked(account, tokenId)).toEthSignedMessageHash();
require(digest.recover(signature) == _signer, "Invalid signature");
_safeMint(account, tokenId);
}签脚本:
const digest = ethers.utils.solidityKeccak256(
["address", "uint256"],
[account, tokenId]
);
const sig = await signer.signMessage(ethers.utils.arrayify(digest));6. EIP-191 vs EIP-712:场景决定选型
- EIP-191 仅做前缀标准化,适合简单易读场景。
- EIP-712 引入 struct、哈希结构、domainSeparator,兼容钱包可视化,且天然防重放。
6.1 EIP-712 实战:Uniswap Permit
必填字段:
struct EIP712Domain {
string name;
string version;
uint256 chainId;
address verifyingContract;
}
struct Permit {
address owner;
address spender;
uint256 value;
uint256 nonce;
uint256 deadline;
}链下签名前端代码简写:
const domain = { name, version, chainId, verifyingContract: pairAddr };
const types = { Permit: [...] };
const value = { owner, spender, value, nonce, deadline };
const signature = await signer._signTypedData(domain, types, value);6.2 面子里子的差异
| 维度 | 通用消息 | EIP-712 |
|---|---|---|
| 钱包展示 | 仅 raw | 表单化 |
| 重放防护 | 需手动加域 | domainSeparator 天然隔离 |
| 用户体感 | “盲签” | 可视化确认,风险可察 |
| 实现成本 | 极简 | 需写 Domain 和 TypeHash |
7. FAQ:快速排雷指南
Q1:为什么我的合约同样的输入签名每次都不同?
→ ECDSA 需要高质量的随机数 k,系统是安全的,这是预期行为。切勿复用字节级相同签名否则泄露私钥。
Q2:可以将 EIP-191 和 EIP-712 混在一起吗?
→ 不推荐。钱包只信任特定的 EIP-712 domainSeparator,混合会导致前端不一致及重放风险。
Q3:在测试网练手是否需要 ChainId?
→ 建议加。现在测试网 ID 多,省得后期跨网被撞签名。
Q4:为什么 MetaMask 的确认窗口一片乱码?
→ 没有用 EIP-712。把数据打包进 struct 并设置 domain,立即变“表格”。
Q5:如何测试“重放”漏洞?
→ 部署两份内容相同的合约 A、B,给 A 发出的签名在 B 执行即命中重放。修复后再做相同操作应被 revert。
Q6:合约升级了还能复用旧签名吗?
→ 在 domainSeparator 里加 address(this),升级后相当于不同域,旧签名失效。
8. 小结与实践清单
| 芯片级任务 | 检查点 |
|---|---|
| 交易签名 | nonce、gas、ChainId、RLP、Keccak256 |
| 消息签名 | 决定用 personal_sign 还是 EIP-712 |
| 防重放 | nonce、已用 mapping、domainSeparator、address、ChainId |
| 交互钱包 | 测试 eth_signTypedData_v4,确保人类能看懂内容 |
最后,祝各位开发者把“看似玄学”的以太坊签名玩得跟小孩积木一样顺手。
(完)