深度解析 Semaphore:基于零知识证明的匿名信号系统
本文 Technical 程度:⭐⭐⭐
什么是 Semaphore?我们先从用户视角看一个完整例子,再看内部实现。
一个端到端的例子:匿名投票
假设你参加了一个 DAO,里面有提案 #42。投票的要求是:
- 只有 DAO 成员才能投票
- 每个成员对同一个提案只能投一次票
- 投票是匿名的:链上不应暴露"是谁投了票"
用 Semaphore,可以做到:
- 你在某个时间点注册成为"成员",你的身份被加入一个链上的 Merkle Tree。
- 你在本地生成一个零知识证明,证明:
- 你在那棵树里(即你是成员)
- 你这次投票对应的是提案 #42
- 你之前没有对提案 #42 投过票
- 你把这个证明和你的投票内容(YES/NO)提交给合约。
- 合约验证证明,如果有效,就在计票中加一,同时记下你在提案 #42 下已经用过一次投票资格。
全程链上不会出现你的地址、你的身份 ID,只出现"某个合法成员对提案 #42 投了一票 YES"。
1. 核心数据结构
Semaphore 的核心数据结构可以分为三块:身份、群组、无效化符。
A. 身份 (Identity)
用户在本地生成一个"身份密钥对",由两个 256-bit 随机数构成:
- Private(只在电路中作为 witness 使用):
trapdoornullifier
- Public:Commitment
Commitment = Poseidon(nullifier, trapdoor)
这个 Commitment 会被提交到链上的 Merkle Tree 中,作为"我是一个成员"的公开标记。
为什么要两个随机数而不是一个?
核心原因是把"注册绑定"和"活动唯一性"拆成两个独立维度,可以在数学上更好地隔离攻击面。继续往下看你就会看到:trapdoor 只参与 Commitment,nullifier 主要参与每个活动的唯一性。读者如果不想深究,可以简单记住:两个随机数是安全性和灵活性的设计选择,不是多余的。
B. 群组 (Group / Merkle Tree)
链上维护一棵增量 Merkle Tree,用来存所有成员的 Commitment:
- Leaves(叶子): 每个 leaf 是
Commitment,即Poseidon(nullifier, trapdoor)。 - Root(树根): 代表当前成员集合的状态快照。
在零知识电路中,用户会把:
- 自己的
nullifier、trapdoor(私有输入) - 一条通往 Root 的
Merkle Path(兄弟节点数组 + 路径方向数组,私有输入) - 当前的
Merkle Root(公开输入)
一起送进电路,用来证明"我对应的 Commitment 在这棵树下"。
C. 无效化符 (Nullifier Hash)
为了防止一个用户在同一个活动 (Scope) 下进行多次操作,Semaphore 引入 Nullifier Hash 作为"该用户在该活动下的一次性标记"。
定义为:
NullifierHash = Poseidon(nullifier, scope)
- 对于同一个用户,同一个
scope,NullifierHash是确定的。 - 对于同一个用户,不同
scope,NullifierHash完全不同。 - 由于
nullifier是私有随机数,外界看到一堆NullifierHash也无法把不同 scope 下的同一用户关联起来。
- 某个提案投票:
scope = 42 - 某次空投活动:
scope = 2024_airdrop_1 - 某个 DApp 的"只限一次评论"活动:
scope = comment_board_123
总之,同一个 scope 表示同一类"只能做一次"的活动。
2. 系统架构与链上状态
从链上角度看,Semaphore 合约只维护两类状态:
- 成员注册表 (Identity Registry): 用一棵 Merkle Tree 维护所有
Commitment。 - 活动参与记录 (Nullifier Map): 用一个
mapping记录哪些NullifierHash已经被使用过。
2.1 链上存储结构
graph TD
subgraph "Smart Contract Storage"
subgraph "1. Merkle Tree"
direction TB
Root["Merkle Root"]
N1((Hash))
N2((Hash))
L1["Commit(A)"]
L2["Commit(B)"]
L3["Commit(C)"]
Root --> N1
Root --> N2
N1 --> L1
N1 --> L2
N2 --> L3
end
subgraph "2. Nullifier Map"
direction TB
MapTitle["mapping(hash => bool)"]
Rec1["H(A, vote42): True"]
Rec2["H(B, vote42): True"]
Rec3["H(A, airdrop): True"]
MapTitle --- Rec1
MapTitle --- Rec2
MapTitle --- Rec3
end
end
style Root fill:#ffcc99,stroke:#333,stroke-width:2px
style MapTitle fill:#99ccff,stroke:#333,stroke-width:2px
直观理解:
- Merkle Tree:只关心"谁是成员",不关心"谁做了什么"。
- Nullifier Map:只关心"某个成员 (NullfierHash) 在某个 scope 下是否已经用过一次",不关心"这个 hash 来自谁"。
3. 交互流程:从电路到合约
当用户对一个具体活动(比如提案 #42)发起一次匿名操作时,流程分为两步:
- 链下:生成零知识证明 (Proof)
- 链上:验证证明并更新状态
3.1 链下电路计算
电路输入分为两类:
- 私有输入 (Private / Witness):
trapdoornullifiermerklePathSiblings[]merklePathIndices[](每层是 0/1,表示在左还是右)
- 公开输入 (Public Inputs):
merkleRootscopesignal(例如:YES=1 / NO=0,或目标地址等)
电路内部大致做三件事:
(1) 成员资格证明
计算叶子:
leaf = Poseidon(nullifier, trapdoor)
从叶子和 merklePath 一层层向上哈希,最终得到 calculatedRoot,并约束:
calculatedRoot == merkleRoot // merkleRoot 是公开输入
关于 indices 是否泄露位置?
在电路中,merklePathSiblings 和 merklePathIndices 都作为私有输入使用,只出现在证明内部,不会作为公开输入暴露给验证者。所以验证者只能看到最终的 merkleRoot,看不到你是第几个叶子。
(2) 无效化符生成
电路内部计算:
nullifierHash = Poseidon(nullifier, scope)
并把这个 nullifierHash 作为公开输出的一部分(Public Output),供链上合约使用。
这一步确保:同一个用户 + 同一个 scope,总是产生同一个 nullifierHash,方便链上用 mapping 检查有没有用过。
(3) 信号绑定 (Signal Binding)
目的: 防止中间人拿你的 Proof,替换其中的业务内容 signal 之后重新提交,导致你本来投的是 YES,链上被记录成 NO。
为此,需要确保:
Signal 绑定的核心要求
电路内部的计算真实依赖 signal,否则 signal 就是个"摆设参数",攻击者可以改它而不影响 Proof 的有效性。
最简单的一种方式(简化描述):
- 电路中增加一个约束,例如:
dummy = signal * signal
- 并把
signal作为 public input 纳入验证。
如此一来,如果你事后改 signal,public inputs 变了,但 Proof 是针对原来那组 public inputs 生成的,验证者会发现不匹配,验证失败。
如果不做 signal 绑定会怎样?
攻击者可以:
- 拦截你发给链上的 Proof 和参数;
- 把
signal = YES改成signal = NO; - 由于电路内部根本没用到
signal,Proof 依然会通过验证; - 结果链上记录的就是你投了 NO,而不是 YES。
因此,"signal 绑定"是防篡改的关键步骤。
3.2 链上验证与状态更新
用户将以下内容发给合约:
root: 当前群组的 Merkle RootnullifierHash: 电路计算出来的 Nullifier Hashsignal: 业务数据(例如投票内容)scope: 活动 ID(例如提案 ID)proof: 零知识证明本身
合约逻辑大致如下(伪代码):
function verifyProof(
uint256 root,
uint256 nullifierHash,
uint256 signal,
uint256 scope,
bytes calldata proof
) external {
// 1. 检查 Root 是否是当前/近期合法 Root
require(merkleTreeRoots[root], "Root not found");
// 2. 检查该成员在此 scope 下是否已参与过
// nullifierHash = Poseidon(nullifier, scope),已包含 scope 信息
require(!nullifierMap[nullifierHash], "Already used in this scope");
// 3. 用 verifier 合约验证 ZK 证明
bool isValid = verifier.verify(proof, [root, nullifierHash, signal, scope]);
require(isValid, "Invalid proof");
// 4. 标记该 nullifierHash 已使用
nullifierMap[nullifierHash] = true;
// 5. 执行业务逻辑(例如为 YES 票计数)
_applySignal(signal);
}
这里第 2 步就是防止用户在同一个活动下参与多次(比如重复投票、重复领空投)。
4. 总结
综合来看,Semaphore 通过以下方式实现了匿名、安全、一次性的信号系统:
- 身份层 (Identity): 用户本地持有
(nullifier, trapdoor)这对私钥,只公开Commitment。 - 群组层 (Merkle Tree): 链上只存
Commitment的树结构,通过 Root 作为 membership 的全局状态。 - 活动层 (Scope + NullifierHash): 每个活动有自己的
scope,通过Poseidon(nullifier, scope)生成一次性标记,在合约中用mapping记录是否已使用。 - 电路层 (ZK Circuit): 连接私有身份和链上状态:
- 在不泄露身份的前提下证明"我是树中的一个成员"
- 保证"这次操作对应的 scope 下,我还没做过"
- 绑定业务信号
signal,防止中间人篡改
这样,从用户角度看,就是:我可以匿名地证明"我有资格,而且这是我在这个活动里的第一次也是唯一一次操作"。