关于 midnight-security
面向 Midnight 合约开发者的安全技能,帮助识别公开数据、隐私泄漏和交易设计风险。内容涵盖审计清单,以及适用于 ZK 应用的防御模式。
主要功能
- 01账本与电路可见性说明
- 02合约隐私审计清单
- 03承诺与域分隔建议
- 04Witness 校验与重放检查
- 05Merkle 成员证明模式
- 06交易失败风险指南
适用场景
- 01部署前检查账本字段
- 02检查电路参数是否含敏感数据
- 03设计匿名许可名单与一次性令牌
- 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-privatedocs.midnight.network/concepts/how-midnight-works/semanticsdocs.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 树深度选择
| 深度 | 最大叶子数 | 对电路的影响 | 适用场景 |
|---|---|---|---|
| 8 | 256 | 最小 | 小型允许列表、测试 |
| 10 | 1,024 | 低 | 小型用户群 |
| 16 | 65,536 | 中等 | 中型 DApp |
| 20 | 1,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 + 承诺揭示模式 |