mcpField
M
midnight-security

midnight-security

github.com/kali-decoder安全18126 次安装1 天前更新
代码仓库

关于 midnight-security

面向 Midnight 合约开发者的安全技能,帮助识别公开数据、隐私泄漏和交易设计风险。内容涵盖审计清单,以及适用于 ZK 应用的防御模式。

主要功能

  1. 01账本与电路可见性说明
  2. 02合约隐私审计清单
  3. 03承诺与域分隔建议
  4. 04Witness 校验与重放检查
  5. 05Merkle 成员证明模式
  6. 06交易失败风险指南

适用场景

  1. 01部署前检查账本字段
  2. 02检查电路参数是否含敏感数据
  3. 03设计匿名许可名单与一次性令牌
  4. 04评估重放、抢先交易与费用风险

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/kali-decoder/midnight-skills --skill midnight-security

安装后在 Claude Code 中输入/midnight-security确认已加载。

详细介绍

Midnight 安全技能

Midnight 的隐私模型是在公有链上采用选择加入式隐私。ZK 证明证明执行的正确性——但不会隐藏交易的存在、其结构,或 export ledger 中的任何内容。在 Midnight 上实现安全,意味着要准确了解哪些信息会泄露,并设计合约,让泄露的信息都是有意公开的。

主要参考资料:

  • docs.midnight.network/concepts/how-midnight-works/keeping-data-private
  • docs.midnight.network/concepts/how-midnight-works/semantics
  • docs.midnight.network/concepts/how-midnight-works/smart-contracts
  • 官方安全文档:截至 2026 年仍“即将推出”——本技能填补了这一空缺

1) 基本可见性规则

export ledger 中的所有内容都是公开的。witness 中的所有内容都是私密的。证明是公开的。witness 数据不是。

更具体地说,对于每项 ledger 操作:

操作公开的信息
ledger.insert(v)值 v
ledger.lookup(k)键 k 和返回值
set.insert(v)值 v
set.member(f(x))值 f(x)——而非 x 本身
map.insert(k, v)键 k 和值 v
counter.increment(n)增量 n 和新值
merkleTree.insert(v)不会公开 v——只会公开树有所增长
merkleTree.checkRoot(path)有人证明了成员资格——但不会说明是哪一项
电路参数全部——它们都是公开转录的一部分
witness 返回值链上不会公开任何内容——ZK 证明只证明这些值确实存在

公开转录是链上记录的内容:ledger 的读取、写入,以及这些操作的结构。Witness 值只存在于证明的私有输入中——它们绝不会被提交上链。


2) 隐私审计清单

发布前,请用此清单检查每份合约。

Ledger 字段
  • 对每个 export ledger 字段:谁能读取它?这是否可以接受?
  • 是否有任何 export ledger 字段是应保留在本地的用户数据(witness)?
  • 这个字段是否只是因为前端需要读取它才存在?如果只有一位用户关心它,就不应将其放到链上。
  • 对于任何 Map<K, V>:键 K 本身是否敏感?(Map 键在插入或查询时都是公开的。)
  • 需要匿名性时,是否使用了 Set<T>?(Set.member(v) 会公开 v——请改用 MerkleTree。)
电路参数
  • 每个电路参数是否都属于公开转录的一部分?(是,全部都是。)
  • 是否有任何敏感值被直接作为电路参数传入,而非通过 witness 传入?
承诺与哈希
  • 是否对取值范围较小的值(例如布尔值、投票、角色)使用了 persistentHash?→ 可被暴力破解 → 请改用带新鲜 nonce 的 persistentCommit。
  • 是否有两个承诺复用了同一个 nonce?→ 相同输入会在链上生成相同的承诺 → 可被关联。
  • 是否将 transientHash/transientCommit 用于 ledger 存储?→ 合约升级后无法保留 → 请改用 persistent*。
域分隔符
  • 每次调用 persistentHash 时,是否都包含唯一的域分隔符字符串?
  • 是否使用同一个密钥,在没有域分隔的情况下派生多个公钥?→ 密钥相同、哈希相同 → 不同用途会被关联。
  • 承诺和其 nullifier 是否使用了相同的域分隔符?→ 可能发生碰撞。
Witness 信任
  • 每个 witness 输出在用于逻辑前,是否都通过 assert 进行了验证?
  • 是否有电路在未进行检查的情况下,假设 witness 会返回“诚实”的数据?
重放与复用
  • 是否有任何使用密钥进行身份验证的模式,未使用序列计数器或 nullifier?→ 重放攻击。
  • 是否有任何承诺可以重复使用,以再次授权同一操作?→ 缺少 nullifier。
交易结构泄露
  • 即使数据是隐藏的,调用特定电路是否会暴露意图?(例如,调用 submitVote 会暴露有人投了票)
  • 电路中 merkleTree.insert 的调用次数是否会泄露有关私有输入的信息?

3)即使使用见证值也会泄露的内容

无论采取何种隐私措施,这些信息始终对任何链上观察者可见:

  • 调用了哪个合约——合约地址是公开的
  • 调用了哪个电路——索引器中的 entryPoint 字段
  • 调用时间——区块时间戳
  • 发生了一笔交易——交易是否存在始终是公开的
  • 电路参数值——传递给 export circuit 的每个参数
  • 所有账本写入——每个发生变化的 export ledger 字段
  • 公开记录的结构——发生了多少次读取、写入和插入(但这些操作背后的私有值不可见)

**实际影响:**如果电路将 vote: Boolean 作为参数,即使你从未直接将其写入账本,投票内容仍然是公开的。应将其移到见证值中。


4)数据泄露模式(常见错误)

模式 1:将敏感值作为电路参数
// ❌ vote is a circuit argument → public transcript → everyone sees it
export circuit submitVote(vote: Boolean): [] {
  votes.insert(disclose(makeCommitment(vote)));
}

// ✅ vote comes from witness → stays in proof's private inputs
witness userVote(): Boolean;

export circuit submitVote(): [] {
  const vote = userVote();  // private
  votes.insert(disclose(makeCommitment(vote)));  // only commitment is public
}
模式 2:需要匿名性时使用 Set
// ❌ Set.member(v) reveals v — which user's commitment is being checked
export ledger members: Set<Bytes<32>>;

export circuit prove(): [] {
  const pk = publicKey(secretKey());
  assert(members.member(pk), "not a member");  // reveals pk
}

// ✅ MerkleTree proves membership without revealing which entry
export ledger members: MerkleTree<16, Bytes<32>>;
witness findMemberPath(pk: Bytes<32>): MerkleTreePath<16, Bytes<32>>;

export circuit prove(): [] {
  const pk = publicKey(secretKey());
  const path = findMemberPath(pk);
  assert(
    members.checkRoot(merkleTreePathRoot<16, Bytes<32>>(path)),
    "not a member"
  );
  // Only proves: "someone in the tree called this." Does not reveal who.
}
模式 3:对取值范围较小的值进行哈希但不使用随机数
// ❌ persistentHash over Boolean → 2 possible outputs → trivially brute-forced
export ledger commitment: Bytes<32>;
export circuit commit(vote: Boolean): [] {
  commitment = disclose(persistentHash<Boolean>(vote));
}

// ✅ persistentCommit with fresh nonce → infeasible to brute-force
witness voteNonce(): Bytes<32>;
export circuit commit(vote: Boolean): [] {  // vote still a circuit arg here — see pattern 1
  const nonce = voteNonce();
  commitment = disclose(persistentCommit<Boolean>(vote, nonce));
}
// Combine with pattern 1 to also move vote to witness
模式 4:重复使用随机数关联承诺
// ❌ same secret key as nonce → if same value committed twice, identical on-chain
export circuit recordBalance(amount: Uint<64>): [] {
  const sk = secretKey();
  const c = persistentCommit<Uint<64>>(amount, sk);  // reusing sk as nonce
  commitments.insert(disclose(c));
}

// ✅ derive a unique nonce per commitment using round counter or fresh randomness
export ledger round: Counter;
export circuit recordBalance(amount: Uint<64>): [] {
  const sk = secretKey();
  const nonce = persistentHash<Vector<2, Bytes<32>>>(
    [sk, pad(32, "balance-nonce"), round as Field as Bytes<32>]
  );
  const c = persistentCommit<Uint<64>>(amount, nonce);
  commitments.insert(disclose(c));
  round.increment(1);
}
模式 5:缺少域分隔符——不同用途间重复使用密钥
// ❌ same sk → same hash for auth and for commitment → linkable across contracts
pure circuit publicKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Bytes<32>>(sk);
}

pure circuit commitmentKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Bytes<32>>(sk);  // identical output
}

// ✅ unique domain separator per purpose
pure circuit authPublicKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:auth:v1"), sk]);
}

pure circuit commitmentKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:commit:v1"), sk]);
}
模式 6:不可信的见证值输出
// ❌ trusting that the DApp-provided witness returns the right balance
export circuit transfer(to: Bytes<32>, amount: Uint<64>): [] {
  const balance = userBalance();  // could return anything
  balances.insert(to, disclose(balance - amount));
}

// ✅ assert all witness outputs before using them
export circuit transfer(to: Bytes<32>, amount: Uint<64>): [] {
  const balance = userBalance();
  assert(balance >= amount, "insufficient balance");
  assert(balance <= MAX_SUPPLY, "invalid balance");
  balances.insert(to, disclose((balance - amount) as Uint<64>));
}

5)防御模式(完整实现)

基于哈希的身份验证(ZK 签名)

证明知晓密钥,但不泄露密钥。序列计数器可防止跨轮次重放。

pragma language_version >= 0.22;
import CompactStandardLibrary;

export ledger admin: Bytes<32>;
export ledger round: Counter;

witness secretKey(): Bytes<32>;

constructor() {
  admin = publicKey(secretKey(), 0);
  round.increment(1);
}

export circuit adminAction(): [] {
  const sk = secretKey();
  const currentRound = round as Field as Bytes<32>;
  assert(admin == publicKey(sk, currentRound), "not authorized");
  // ... do the action
  round.increment(1);  // invalidates old proofs against this round
  admin = disclose(publicKey(sk, round as Field as Bytes<32>));
}

pure circuit publicKey(sk: Bytes<32>, roundBytes: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<3, Bytes<32>>>([
    pad(32, "myapp:admin:v1"),  // domain separator
    roundBytes,                  // round prevents replay
    sk,
  ]);
}
承诺/空值符(单次使用的匿名令牌)

证明某个令牌存在于 Merkle 树中且尚未被使用,但不透露具体是哪个令牌。

pragma language_version >= 0.22;
import CompactStandardLibrary;

export ledger commitments: HistoricMerkleTree<16, Bytes<32>>;
export ledger nullifiers: Set<Bytes<32>>;
export ledger counter: Counter;

witness secretKey(): Bytes<32>;
witness findCommitmentPath(c: Bytes<32>): MerkleTreePath<16, Bytes<32>>;

export circuit addToken(commitment: Bytes<32>): [] {
  commitments.insert(disclose(commitment));
}

export circuit spend(): [] {
  const sk = secretKey();
  const c = commitment(sk);
  const path = findCommitmentPath(c);

  // 1. Prove token is in the tree (without revealing which)
  assert(
    commitments.checkRoot(merkleTreePathRoot<16, Bytes<32>>(path)),
    "commitment not in tree"
  );

  // 2. Prove token has not been spent
  const nul = nullifier(sk);
  assert(!nullifiers.member(disclose(nul)), "already spent");

  // 3. Record the spend — reveals nullifier, not the commitment
  nullifiers.insert(disclose(nul));
  counter.increment(1);
}

// CRITICAL: commitment and nullifier MUST use different domain separators
// If they shared a domain, the same sk would produce the same hash for both
pure circuit commitment(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:commit:v1"), sk]);
}

pure circuit nullifier(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:nullifier:v1"), sk]);
}

**当树频繁插入数据,否则会导致尚未完成的证明失效时,请使用 HistoricMerkleTree 而不是 MerkleTree。**如果证明始终基于当前根生成,且不会移除项目,则使用普通的 MerkleTree。

匿名允许列表(Merkle 成员资格)

授权一组用户,但不透露具体是哪位用户在操作。

pragma language_version >= 0.22;
import CompactStandardLibrary;

export ledger allowlist: MerkleTree<16, Bytes<32>>;

witness secretKey(): Bytes<32>;
witness findAllowlistPath(pk: Bytes<32>): MerkleTreePath<16, Bytes<32>>;

export circuit addToAllowlist(commitment: Bytes<32>): [] {
  // Admin adds a commitment — reveals commitment (hash of user's key)
  // The commitment itself doesn't reveal who the user is unless brute-forced
  allowlist.insert(disclose(commitment));
}

export circuit allowlistedAction(): [] {
  const sk = secretKey();
  const pk = allowlistKey(sk);
  const path = findAllowlistPath(pk);

  assert(
    allowlist.checkRoot(merkleTreePathRoot<16, Bytes<32>>(path)),
    "not on allowlist"
  );

  // Action proceeds. Observer knows: "someone on the allowlist did this."
  // Observer does NOT know: which allowlisted user.
}

pure circuit allowlistKey(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "myapp:allowlist:v1"), sk]);
}

6)交易语义——安全影响

Midnight 交易分两个阶段执行,且失败时的行为各不相同:

well-formedness check  →  guaranteed phase  →  fallible phase
阶段失败行为费用行为
格式正确性整笔交易被拒绝,不会被纳入区块不收取费用
保证阶段整笔交易被拒绝,不会被纳入区块不收取费用
可失败阶段保证阶段的效果会保留;可失败阶段会回滚费用不予退还

安全影响:

**部分成功确实可能发生。**如果合约在保证阶段执行了操作(例如销毁令牌),随后在可失败阶段失败(例如余额检查失败),销毁操作将永久生效,但余额不会更新。设计状态转换时,应确保操作要么原子性地成功,要么保证阶段不产生任何重要后果。

**可失败阶段失败时,费用不予退还。**如果攻击者能导致可失败阶段失败,就能耗尽用户钱包中的 DUST。请确保可失败电路中的 assert 条件不会受外部状态影响,以免攻击者能在生成证明和提交交易之间操纵该状态。

**通过公开记录抢先交易。**由于公开记录(包括调用了哪个电路以及所有电路参数)会在交易最终确定前显示在内存池中,观察者可能会在交易上链前根据这些信息采取行动。如果交易顺序很重要(例如交换),应设计包含滑点限制的电路,或使用承诺后揭示的方式延后公开敏感数据。


7) ZK 证明能保证和不能保证的事项

ZK 证明能保证:

  • 电路逻辑使用某些有效的私有输入正确运行
  • 公共记录准确记录了电路对账本的读取和写入
  • 证明者在生成证明时知道一个满足所有电路约束的见证

ZK 证明不能保证:

  • 私有输入从应用的角度来看是“诚实的”(只能保证它们满足电路中的 assert 语句)
  • 与证明者身份相关的任何信息
  • 使用的见证值与之前调用时相同
  • 电路参数的机密性——这些参数始终公开

实际影响: 合约关心的每个不变量都必须通过电路中的 assert 强制执行。ZK 证明只能证明电路检查的内容。


8) Merkle 树深度选择

深度最大叶子数对电路的影响适用场景
8256最小小型允许列表、测试
101,024低小型用户群
1665,536中等中型 DApp
201,048,576较高大型用户群
32~40 亿显著最大规模

使用满足需求的最小深度。每增加一层,都会增加电路大小和证明耗时。溢出(插入数量超出容量)会导致运行时错误——请根据预期的最大数量设置树的大小。

HistoricMerkleTree 会存储所有历史根,因此其存储成本高于 MerkleTree。只有在需要针对过去的根证明成员资格时才使用它(例如,生成证明和提交证明之间树经常更新的情况)。


9) 安全速查表

风险不安全模式安全模式
链上暴露敏感值将私有数据作为电路参数移至 witness
成员资格检查暴露身份Set<T> 成员资格检查MerkleTree<n, T> 路径证明
可暴力破解的哈希对小域使用 persistentHash使用带新随机数的 persistentCommit
承诺之间存在关联多次提交使用相同随机数每次提交使用轮次计数器或新随机数
在不同用途之间复用密钥相同的 sk → 所有场景下都得到相同哈希每种用途使用唯一的域分隔符
承诺与 nullifier 冲突两者使用相同域使用不同的 pad(32, ...) 字符串
重放攻击授权时没有序列计数器在公钥推导中包含轮次/计数器
双重支付未跟踪 nullifier使用 Set<Bytes<32>> nullifier 注册表
不可信的见证直接使用见证输出使用前对所有见证输出执行 assert
升级导致承诺失效在账本中使用 transientHash使用 persistentHash / persistentCommit
部分成功陷阱在保证阶段执行重要操作让保证阶段尽可能精简
抢先交易在电路参数中包含敏感参数使用 witness + 承诺揭示模式

评论

支持 Markdown