Steven's Blog
← Back to home

深度解析 Semaphore:基于零知识证明的匿名信号系统

本文 Technical 程度:⭐⭐⭐

什么是 Semaphore?我们先从用户视角看一个完整例子,再看内部实现。

一个端到端的例子:匿名投票

假设你参加了一个 DAO,里面有提案 #42。投票的要求是:

  • 只有 DAO 成员才能投票
  • 每个成员对同一个提案只能投一次票
  • 投票是匿名的:链上不应暴露"是谁投了票"

用 Semaphore,可以做到:

  1. 你在某个时间点注册成为"成员",你的身份被加入一个链上的 Merkle Tree。
  2. 你在本地生成一个零知识证明,证明:
    • 你在那棵树里(即你是成员)
    • 你这次投票对应的是提案 #42
    • 你之前没有对提案 #42 投过票
  3. 你把这个证明和你的投票内容(YES/NO)提交给合约。
  4. 合约验证证明,如果有效,就在计票中加一,同时记下你在提案 #42 下已经用过一次投票资格

全程链上不会出现你的地址、你的身份 ID,只出现"某个合法成员对提案 #42 投了一票 YES"。


1. 核心数据结构

Semaphore 的核心数据结构可以分为三块:身份、群组、无效化符

A. 身份 (Identity)

用户在本地生成一个"身份密钥对",由两个 256-bit 随机数构成:

  • Private(只在电路中作为 witness 使用):
    • trapdoor
    • nullifier
  • 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(树根): 代表当前成员集合的状态快照。

在零知识电路中,用户会把:

  • 自己的 nullifiertrapdoor(私有输入)
  • 一条通往 Root 的 Merkle Path(兄弟节点数组 + 路径方向数组,私有输入)
  • 当前的 Merkle Root(公开输入)

一起送进电路,用来证明"我对应的 Commitment 在这棵树下"。

C. 无效化符 (Nullifier Hash)

为了防止一个用户在同一个活动 (Scope) 下进行多次操作,Semaphore 引入 Nullifier Hash 作为"该用户在该活动下的一次性标记"。

定义为:

NullifierHash = Poseidon(nullifier, scope)
  • 对于同一个用户,同一个 scopeNullifierHash 是确定的。
  • 对于同一个用户,不同 scopeNullifierHash 完全不同。
  • 由于 nullifier 是私有随机数,外界看到一堆 NullifierHash 也无法把不同 scope 下的同一用户关联起来。
scope 可以是什么?
  • 某个提案投票:scope = 42
  • 某次空投活动:scope = 2024_airdrop_1
  • 某个 DApp 的"只限一次评论"活动:scope = comment_board_123

总之,同一个 scope 表示同一类"只能做一次"的活动


2. 系统架构与链上状态

从链上角度看,Semaphore 合约只维护两类状态:

  1. 成员注册表 (Identity Registry): 用一棵 Merkle Tree 维护所有 Commitment
  2. 活动参与记录 (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)发起一次匿名操作时,流程分为两步:

  1. 链下:生成零知识证明 (Proof)
  2. 链上:验证证明并更新状态

3.1 链下电路计算

电路输入分为两类:

  • 私有输入 (Private / Witness):
    • trapdoor
    • nullifier
    • merklePathSiblings[]
    • merklePathIndices[](每层是 0/1,表示在左还是右)
  • 公开输入 (Public Inputs):
    • merkleRoot
    • scope
    • signal(例如: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 绑定会怎样?

攻击者可以:

  1. 拦截你发给链上的 Proof 和参数;
  2. signal = YES 改成 signal = NO
  3. 由于电路内部根本没用到 signal,Proof 依然会通过验证;
  4. 结果链上记录的就是你投了 NO,而不是 YES。

因此,"signal 绑定"是防篡改的关键步骤。

3.2 链上验证与状态更新

用户将以下内容发给合约:

  • root: 当前群组的 Merkle Root
  • nullifierHash: 电路计算出来的 Nullifier Hash
  • signal: 业务数据(例如投票内容)
  • 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,防止中间人篡改

这样,从用户角度看,就是:我可以匿名地证明"我有资格,而且这是我在这个活动里的第一次也是唯一一次操作"。