DEV Community

richard202605
richard202605

Posted on

Midnight dApp 部署前安全检查清单:开发者完整指南

Midnight dApp 部署前安全检查清单:开发者指南

Midnight Network 通过零知识证明和可编程隐私控制,为 Web3 带来了隐私保护智能合约。但强大的隐私能力也意味着更高的安全要求。本清单帮助你在 dApp 上线前发现常见漏洞。


为什么需要这份清单

Midnight 的 Compact 语言默认保护隐私——所有数据都是私有的,除非你显式调用 disclose()。这意味着:

  • 一个错误放置的 disclose() 可能永久泄露敏感数据
  • Witness 函数在 ZK 电路外运行,可以被操纵
  • ownPublicKey() 函数有一个已知漏洞,很多开发者忽略了
  • 重放保护需要仔细实现 nonces 和 nullifiers

让我们系统地检查每个安全领域。


部署前检查清单

✅ 1. disclose() 审计——无秘密泄露

disclose() 是将私有数据移动到公开账本的唯一机制。代码中的每个 disclose() 调用都是一个有意识地公开某数据的决定。

需要检查的内容:

// ❌ 错误:过早公开会在后续操作中暴露值
export circuit store(flag: Boolean): [] {
  const secret = disclose(getSecret());  // 公开太早了!
  const derived = computeValue(secret);  // `secret` 现在可见了
  result = disclose(flag) ? derived : 0;
}

// ✅ 正确:仅在使用点公开
export circuit store(flag: Boolean): [] {
  const secret = getSecret();
  const derived = computeValue(secret);  // 仍然私有
  result = disclose(flag) ? disclose(derived) : 0;  // 具体、有针对性的公开
}
Enter fullscreen mode Exit fullscreen mode

检查项:

  • [ ] 搜索代码库中的每个 disclose() 调用
  • [ ] 验证每次公开都是有意且最小化的
  • [ ] 确保 disclose() 尽可能靠近使用点
  • [ ] 检查没有代码路径意外公开应保持私有的值
  • [ ] 对必须保持私有的值使用下划线前缀(_sk_secret

专业提示: Compact 编译器会捕获将私有值赋给账本字段而没有 disclose() 的尝试,但不会捕获过早的公开。手动审查是必须的。


✅ 2. ownPublicKey() 使用审查(已知漏洞)

这是 Midnight 开发中最常见的安全错误之一。

⚠️ 警告:不要使用 ownPublicKey() 进行调用者验证!

ownPublicKey() 技术上是一个 witness 函数。这意味着每个用户的前端都可以产生恶意的返回值。攻击者可以修改本地 witness 实现来返回任意公钥。

// ❌ 严重漏洞:使用 ownPublicKey() 进行授权
export circuit transfer(to: Bytes<32>, amount: Uint<64>): [] {
  const sender = ownPublicKey();  // 攻击者可以返回任何密钥!
  assert(balances.member(sender), "No balance");
  // ... 使用伪造的发送者进行转账逻辑
}

// ✅ 正确:基于哈希的身份验证
witness secretKey(): Bytes<32>;

circuit publicKey(_sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([
    pad(32, "midnight:auth:pk"),
    _sk
  ]);
}

export circuit authorizedOperation(newValue: Bytes<32>): [] {
  const _sk = secretKey();
  const pk = publicKey(_sk);
  assert(disclose(pk) == authority, "Authorization failed");
  ledgerValue = disclose(newValue);
}
Enter fullscreen mode Exit fullscreen mode

检查项:

  • [ ] 搜索代码库中所有 ownPublicKey() 调用
  • [ ] 验证没有一个被用作唯一的授权机制
  • [ ] 将任何授权逻辑替换为基于哈希的模式
  • [ ] 如果必须使用 ownPublicKey(),添加额外的验证层

✅ 3. 重放保护验证(Nonces 和 Nullifiers)

Midnight 使用承诺/空值器模式(来自 Zerocash/Zswap)来防止双重花费,而不暴露哪个资源被花费。

空值器工作原理:

export ledger usedNullifiers: Set<Bytes<32>>;

circuit nullifier(secretKey: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([
    pad(32, "nullifier-domain"),  // 域分隔符!
    secretKey
  ]);
}

export circuit spend(secretKey: Bytes<32>): [] {
  const nul = nullifier(secretKey);
  assert(!usedNullifiers.member(nul), "Already spent");
  usedNullifiers.insert(disclose(nul));
}
Enter fullscreen mode Exit fullscreen mode

关键要求:

  • [ ] 域分隔符:承诺和空值器必须使用不同的域前缀,以防止哈希碰撞攻击
    • 好的:"nullifier-my-dapp-v1" vs "commitment-my-dapp-v1"
    • 坏的:对两者使用相同的前缀
  • [ ] 不重用随机性:永远不要在承诺之间重用随机性——这会启用链接并破坏隐私
  • [ ] 使用 persistentHash:用于存储在账本状态中的值(保证升级后一致)
  • [ ] 回合计数器:为操作包含时间绑定计数器,防止旧证明的重放

✅ 4. 导出账本字段审查

每个 export ledger 字段在链上公开可见。审查每个字段:

export ledger balance: Counter;
export ledger authority: Bytes<32>;
export ledger usedNullifiers: Set<Bytes<32>>;
Enter fullscreen mode Exit fullscreen mode

检查项:

  • [ ] 验证每个账本字段确实需要公开
  • [ ] 检查没有私有数据意外存储在账本字段中
  • [ ] 确保账本字段类型匹配其预期用途
  • [ ] 审查访问模式——谁可以读取和修改每个字段?

✅ 5. Witness 实现正确性

Witness 是链下 TypeScript 函数,在 ZK 电路外运行。它们不被加密验证

// TypeScript 中的 Witness 实现
export const witnesses = {
  secretKey: ({ privateState }: WitnessContext<Ledger, PrivateState>) =>
    [privateState, privateState.secretKey],

  getUserBalance: ({ privateState }: WitnessContext<Ledger, PrivateState>) =>
    [privateState, privateState.balance],
};
Enter fullscreen mode Exit fullscreen mode

安全影响:

  • 每个用户提供自己的 witness 实现
  • 攻击者可以从 witness 函数返回任何值
  • 合约逻辑永远不能信任 witness 值而不验证

检查项:

  • [ ] 每个 witness 输出在电路中使用前都用 assert 验证
  • [ ] 没有关键逻辑仅依赖 witness 返回值
  • [ ] Witness 函数返回一致的 [PrivateState, ReturnValue] 元组
  • [ ] Witness 中没有可能在调用之间变化的外部依赖
  • [ ] 测试 witness 实现的多次调用一致性

✅ 6. 版本兼容性确认

Midnight 的工具链发展迅速。部署前:

检查项:

  • [ ] 验证 pragma language_version 与安装的 Compact 编译器匹配
  • [ ] 检查所有依赖(midnight-mcp、proof-server)版本兼容
  • [ ] 查看 Midnight 更新日志 了解破坏性变更
  • [ ] 使用合约中指定的确切版本测试编译

✅ 7. 测试网上证明生成测试

在主网之前,验证你的证明生成管道端到端工作。

测试网层级:
| 层级 | 目的 | 代币 |
|------|------|------|
| 本地开发网 | 开发 | 无限 |
| 预览网 | 集成测试 | 测试 NIGHT |
| 预生产网 | 最终验证 | 预生产水龙头 |

检查项:

  • [ ] 部署合约到本地开发网并验证所有电路
  • [ ] 测试每个电路路径的证明生成
  • [ ] 验证 proof-server 处理并发请求
  • [ ] 测试边界情况:零值、最大值、空输入
  • [ ] 运行完整交易生命周期:部署→交互→验证状态
  • [ ] 在预生产网测试后再上主网

Proof 服务器验证:

# 检查 proof 服务器是否运行
curl http://localhost:6300/health

# 测试证明生成
compact prove --contract ./managed/my-contract \
  --circuit myCircuit \
  --inputs ./test-inputs.json
Enter fullscreen mode Exit fullscreen mode

常见安全陷阱

陷阱 1:信任前端输入

永远不要信任来自前端的数据而不进行链上验证。用户控制他们的客户端。

陷阱 2:缺少域分隔

始终为不同的哈希操作使用唯一的域分隔符:

// 好:不同目的使用不同域
persistentHash<Vector<2, Bytes<32>>>([pad(32, "commitment-v1"), data])
persistentHash<Vector<2, Bytes<32>>>([pad(32, "nullifier-v1"), data])
Enter fullscreen mode Exit fullscreen mode

陷阱 3:过早公开

disclose() 尽可能晚地放在计算链中。

陷阱 4:未验证的 Witness 数据

在电路逻辑中使用 witness 输出之前,始终进行 assert

陷阱 5:忽略可链接性

当隐私重要时,使用回合计数器或其他机制打破交易可链接性。


安全模式快速参考

模式 用例 关键点
基于哈希的认证 调用者验证 不要单独使用 ownPublicKey()
承诺-公开 密封投标、拍卖 两阶段承诺
域分隔符 所有哈希操作 每个目的唯一前缀
回合计数器 打破可链接性 使旧密钥失效
延迟公开 所有 disclose() 调用 最小化暴露窗口

资源


总结

部署 Midnight dApp 前,验证:

  1. disclose() 审计——每次公开都是有意且最小化的
  2. ownPublicKey() 审查——没有依赖它进行授权
  3. 重放保护——正确的 nonces、nullifiers 和域分隔符
  4. 账本字段——没有意外公开私有数据
  5. Witness 验证——所有 witness 输出在使用前都经过断言
  6. 版本兼容性——编译器和依赖对齐
  7. 测试网验证——完整的证明生成管道已测试

隐私保护 dApp 功能强大,但需要额外的细致工作。每次部署前运行此清单,你的用户数据将保持安全。


📌 免责声明: 本指南仅供教育目的。请随时关注最新的 Midnight Network 文档和安全公告。安全领域变化迅速。


觉得有帮助?关注更多 Web3 安全内容。有问题?在下方评论。

Top comments (0)