midnight-security について
Midnightのコントラクト開発者向けセキュリティスキルです。公開データやプライバシー漏えい、トランザクション設計上のリスクを確認するチェックリストと防御パターンをまとめています。
主な機能
- 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.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 証明は、それらが存在したことだけを証明します |
パブリックトランスクリプトとは、チェーンが記録するもの、つまり台帳の読み取り、書き込み、およびそれらの操作の構造です。witness の値は証明のプライベート入力にのみ存在し、オンチェーンに投稿されることはありません。
2) プライバシー監査チェックリスト
リリース前に、すべてのコントラクトに対して以下を確認してください。
台帳フィールド
- すべての
export ledgerフィールドについて、誰が読み取れるか、またそれが許容できるかを確認しましたか? - ローカルに保持すべきユーザーごとのデータが
export ledgerフィールドに含まれていませんか(witness を使用してください)。 - フロントエンドが読み取る必要があるという理由だけで、そのフィールドを用意していませんか?1 人のユーザーだけが必要とするなら、オンチェーンに置くべきではありません。
-
Map<K, V>について、キーK自体は機微情報ですか?(Map のキーは挿入時と検索時に公開されます。) - 匿名性が必要なのに
Set<T>を使用していませんか?(Set.member(v)はvを明らかにします — 代わりにMerkleTreeを使用してください。)
回路の引数
- すべての回路パラメーターについて、パブリックトランスクリプトの一部になっていますか?(はい、すべてが含まれます。)
- 機微情報を witness 経由ではなく、回路の引数として直接渡していませんか?
コミットメントとハッシュ
- 小さな値の集合(例:ブール値、投票、ロール)に対して
persistentHashを使用していませんか?→ 総当たりで特定できます → 代わりに新しいノンスを使用してpersistentCommitを使ってください。 - 同じノンスを再利用するコミットメントがありませんか?→ 入力が同一ならオンチェーンのコミットメントも同一になり、関連付けが可能になります。
- 台帳への保存に
transientHash/transientCommitを使用していませんか?→ コントラクトのアップグレード後に保持されません →persistent*を使用してください。
ドメインセパレーター
- すべての
persistentHash呼び出しに、一意なドメインセパレーター文字列が含まれていますか? - ドメインセパレーションなしに、同じ秘密鍵を使用して複数の公開鍵を導出していませんか?→ 同じ鍵、同じハッシュとなり、異なる用途が関連付けられます。
- コミットメントとそのヌリファイアーの両方に、同じドメインセパレーターを使用していませんか?→ 衝突の可能性があります。
witness の信頼性
- すべての witness 出力について、ロジックで使用する前に
assertで検証していますか? - witness が返すデータを検証せずに「誠実」だと仮定している回路はありませんか?
リプレイと再利用
- シーケンスカウンターやヌリファイアーを使わずに秘密鍵を使用する認証パターンはありませんか?→ リプレイ攻撃につながります。
- 同じアクションを再認証するために再利用できるコミットメントはありませんか?→ ヌリファイアーが不足しています。
トランザクション構造からの漏えい
- 特定の回路を呼び出すことで、データが隠されていても意図が明らかになりますか?(例:
submitVoteを呼び出すと、誰かが投票したことが明らかになります) - 回路内での
merkleTree.insert呼び出し回数から、プライベート入力に関する情報が漏れますか?
3) Witnessを使っても漏れる情報
プライバシー対策にかかわらず、これらの情報はすべて、チェーンを監視するすべての人に常に見えます。
- 呼び出されたコントラクト — コントラクトアドレスは公開されています
- 呼び出された回路 — インデクサーの
entryPointフィールド - 呼び出された時刻 — ブロックのタイムスタンプ
- トランザクションが発生したこと — 発生の事実は常に公開されています
- 回路の引数値 —
export circuitに渡されたすべてのパラメーター - すべての台帳への書き込み — 変更されるすべての
export ledgerフィールド - 公開トランスクリプトの構造 — 読み取り、書き込み、挿入の回数(ただし、それらの背後にあるプライベートな値は含まれません)
実用上の注意: 回路がvote: Booleanを引数として受け取る場合、台帳に直接書き込まなくても投票内容は公開されます。これをWitnessに移してください。
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:値の候補が少ない場合にnonceなしでハッシュする
// ❌ 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:nonceの再利用によるコミットメントの紐付け
// ❌ 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:信頼できないWitnessの出力
// ❌ 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,
]);
}
コミットメント/ヌリファイアー(1回限りの匿名トークン)
どのトークンかを明かさずに、トークンが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]);
}
MerkleTreeの代わりにHistoricMerkleTreeを使用するケース: 頻繁な挿入によって未解決の証明が無効になる場合です。証明を常に現在のルートに対して生成し、アイテムを削除しない場合は、通常の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のトランザクションは、失敗時の挙動が異なる2つのフェーズで実行されます。
well-formedness check → guaranteed phase → fallible phase
| フェーズ | 失敗時の挙動 | 手数料の扱い |
|---|---|---|
| 整形式性 | トランザクション全体が拒否され、取り込まれない | 手数料なし |
| 保証 | トランザクション全体が拒否され、取り込まれない | 手数料なし |
| フォールブル | 保証フェーズの効果は維持され、フォールブルフェーズはロールバックされる | 手数料は没収されます |
セキュリティ上の影響:
部分的な成功は実際に起こります。 コントラクトが保証フェーズで処理(例:トークンのバーン)を行い、その後フォールブルフェーズで失敗した場合(例:残高チェックに失敗)、バーンは取り消されませんが、残高は更新されません。状態遷移は、すべてが不可分に成功するか、保証フェーズで重大な処理が何も行われないように設計してください。
フォールブルフェーズの失敗時には手数料が没収されます。 フォールブルフェーズの失敗を引き起こせる攻撃者は、ユーザーのウォレットからDUSTを奪う可能性があります。フォールブル回路のassert条件が、証明の生成から送信までの間に攻撃者が操作できる外部状態によって発動されないようにしてください。
公開トランスクリプトを利用したフロントランニング。 公開トランスクリプト(呼び出された回路やすべての回路引数を含む)は確定前にメンプールで見えるため、オブザーバーはその情報をもとに、トランザクションが確定する前に行動できる可能性があります。順序が重要な場合(例:スワップ)は、スリッページ制限を含む回路を設計するか、コミットメントと公開を使用して機密データの開示を遅らせてください。
7) ZK 証明で保証されること/されないこと
ZK 証明で保証されること:
- 有効なプライベート入力を使って回路ロジックが正しく実行されたこと
- 公開トランスクリプトが、回路による台帳への読み書きを忠実に記録していること
- 証明時に、証明者がすべての回路制約を満たすウィットネスを知っていたこと
ZK 証明で保証されないこと:
- プライベート入力がアプリケーションの観点から「誠実」であること(回路の
assert文を満たしていることだけが保証されます) - 証明者の身元に関すること
- 前回の呼び出しと同じウィットネス値が使われたこと
- 回路の引数の機密性 — これらは常に公開されます
実際に重要なこと: コントラクトが必要とする不変条件はすべて、回路内の assert で強制する必要があります。ZK 証明が証明するのは、回路がチェックすることだけです。
8) Merkle Tree の深さの選択
| 深さ | 最大リーフ数 | 回路への影響 | 用途 |
|---|---|---|---|
| 8 | 256 | 最小 | 小規模な許可リスト、テスト |
| 10 | 1,024 | 低 | 小規模なユーザーセット |
| 16 | 65,536 | 中程度 | 中規模の DApp |
| 20 | 1,048,576 | 高め | 大規模なユーザーセット |
| 32 | 約 40 億 | 大きい | 最大規模 |
必要最小限の深さを使用してください。レベルを 1 つ追加するごとに、回路サイズと証明時間が増加します。容量を超えて挿入すると、実行時エラーになります。想定される最大数に合わせてツリーのサイズを設定してください。
HistoricMerkleTree は過去のルートをすべて保存するため、MerkleTree よりもストレージコストが高くなります。過去のルートに対するメンバーシップを証明する必要がある場合(たとえば、証明の生成から送信までの間にツリーが頻繁に更新される場合)に限り使用してください。
9) セキュリティ早見表
| リスク | 不適切なパターン | 安全なパターン |
|---|---|---|
| オンチェーンでの機密値の露出 | プライベートデータを回路引数にする | witness に移す |
| メンバーシップから身元が判明する | Set<T> のメンバーシップチェック | MerkleTree<n, T> のパス証明 |
| 総当たり可能なハッシュ | 小さなドメインに対する persistentHash | 新しいノンスを使った persistentCommit |
| コミットメント同士の関連付け | 複数のコミットメントで同じノンスを使う | ラウンドカウンター、またはコミットメントごとに新しいノンスを使う |
| 目的間での鍵の再利用 | 同じ sk を使い、どこでも同じハッシュになる | 目的ごとに一意のドメインセパレーターを使う |
| コミットメントとヌリファイアーの衝突 | 両方に同じドメインを使う | 異なる pad(32, ...) 文字列を使う |
| リプレイ攻撃 | シーケンスカウンターなしの認証 | 公開鍵の導出にラウンド/カウンターを含める |
| 二重使用 | ヌリファイアーの追跡がない | Set<Bytes<32>> のヌリファイアー登録簿 |
| 信頼できないウィットネス | ウィットネスの出力を直接使う | すべてのウィットネス出力を使用前に assert する |
| アップグレードによるコミットメントの破損 | 台帳で transientHash を使う | persistentHash/persistentCommit を使う |
| 部分的な成功による罠 | 成功が保証されたフェーズで重大な処理を行う | 成功が保証されたフェーズを最小限にする |
| フロントランニング | 回路パラメーターに機密性の高い引数を含める | ウィットネスとコミットメント公開パターンを使う |