背景 → Programmable Cryptography とは → それを使ったシステム開発。午後はホワイトボードセッション(題材: KelpDAO 事件)。
Advanced Cryptography Program 2026 / Week 1 自習ノート
このノートは講義スライド 37 枚と、課題 proof-of-exploit の README と同じ順番で進みます。
1 カード = スライド 1〜2 枚(または README の 1 概念)= 主張 1 つ。宿題の 2 関数 build / attack が
どこに当たるかを、毎回「宿題ではここ」で示します。
数は 0〜7 の整数と 1 桁の掛け算だけ。「やってみる」を紙で解いてから答えを開いてください。 制約を 1 本ずつ外して回路を壊せるラボと、検問クイズはそのまま残してあります。
出発点
Week 1 のスライドは前半(1〜14)がプログラム紹介、後半(15〜37)が「なぜ Programmable Cryptography か」と「ZK / MPC / FHE の違い」、
そして技術の実体は課題 proof-of-exploit の README にあります。
このノートはスライド 15〜37 をカードにしたあと、README を同じ形(1 カード = 1 主張 + 1 例)でカードにします。
宿題で書く 2 関数 build(Part A)と attack(Part B)がどこに当たるかを「宿題ではここ」で示します。
読み方
上から順に、1 カードずつ。カード末尾の Q. に自分の言葉で答えられたら次へ。章の終わりの「やってみる」は紙と鉛筆で。数は 0〜7 の整数と 1 桁の掛け算だけです。
この週の 1 行
回路 = 「全部 0 になるべき式の束」。信号は、式で縛らない限り自由。だから式を 1 本忘れると、嘘の値が通る(アンダー制約)。Week 2〜4 は、この「式の束」を分けて計算し(MPC)、見せずに納得させ(ZK)、多項式にまとめる(SNARK)話です。
この頁の構成
対話ドリル — 回路を、1 行ずつ自分の手で
宿題 proof-of-exploit の Part A(組む)と Part B(壊す)を、関数を書く前に手で 1 回通します。1 行打つ → 出た値を貼る → その値を指す 1〜2 文が開く。回路とは何か・アンダー制約とは何かが、説明ではなく数で入ります。
準備(1 行だけ)
python3
ターミナルで python3 と打つだけです。宿題のファイルは要りません(mod 11 の計算だけ)。行頭に空白を入れると IndentationError になるので、貼るときは行頭から。
ドリル 1 — 「= 0 の式」で条件を書く
移項して = 0 → ビット制約 → メンバーシップ → f が嘘をつくと落ちる → AND は掛け算 → 7 本の残りが全部 0。10 行。
ドリル 2 — 1 本抜くと、嘘が通る
正常系 → 嘘は落ちる → リンクを抜く → 通る値が 2 通りから 8 通りへ → 攻撃 witness を選ぶ → 修復 → 正常系は無事。9 行。
スライド 15〜24 / Programmable Cryptography が重要となっている背景
背景 → Programmable Cryptography とは → それを使ったシステム開発。午後はホワイトボードセッション(題材: KelpDAO 事件)。
被害はコードのバグより、監査対象外(設定ミス・運用・ブリッジの信頼設定)で起きる割合が増えている。KelpDAO × LayerZero は 2026 年想定の仮想シナリオとして、その典型を示す。
Q. 「監査で見つからない」事故に、監査を増やす以外の手はあるか。(次のカード)
「攻撃手順 W でこのコントラクトが壊れる」ことを、W を見せずに証明できれば、証明が通った瞬間にコントラクトを自動停止(circuit breaker)できる。原発の非常停止(スクラム)と同じ発想: 人の判断を待たない安全装置。
宿題ではここ: 課題名 proof-of-exploit の由来。Part B の attack() が「壊れる手順(witness)」を作る側、採点器の solver が「壊れないことを確かめる」側。
監査は「人が読んで見つける」。ProgCrypto は「壊れないことを数学で保証する」か「壊れたら数学が検知する」。守れる範囲が変わる。
耐量子: 署名・コミットメントをハッシュベースへ、実行の検証は zkVM の証明が担う(lean Ethereum) プライバシー: イーサリアムのプライバシーをデフォルト化する(PSE、Kohaku、PIR) 機関投資家: 取引内容を隠したまま規則どおりだと示せないと DeFi に入れない
覚える 1 点: 3 つとも「見せずに正しいと示す」= ZK の需要。Week 3・4 の題材はこの需要の中身。
スライド 26〜31 / Programmable Cryptography とは
数学の証明(文章)ではなく、「検証者が機械的に確かめられるデータ」。Goldwasser–Micali–Rackoff(1985)が「対話しながら確かめる」形に広げ、そこから「知識を漏らさない証明」が定義された。
Week 3 で本番: 証明 = (R, s) というデータ、検証 = s·G = R + e·P という計算。
隠す対象 信頼前提 敵対モデル 用途例 ZK prover の witness w prover を信頼しなくてよい(数学) prover(偽証)/verifier(窃取) 年齢証明 — 生年月日を出さず 18 以上 MPC 各参加者の入力 n 台中 k を信頼 参加者(最大 t 名が逸脱) 秘密入札 — 額を漏らさず最高値 FHE 計算データと中間状態 User の鍵管理。Server は見ない 盗聴者/Server(正しさは別途) 暗号文のまま機械学習を委託 ZK: π : ∃w. C(x, w) = 1 MPC: f(x₁, x₂, x₃) FHE: E(x) → E(f(x))
Q. 「1 者が自分のデータを見せずに計算してもらう」はどれか。(FHE)「複数者が持ち寄る」は?(MPC)「条件を満たすことだけ示す」は?(ZK)
1. SOURCE(用途別のコード) age_check.rs(ZK)/auction.rs(MPC)/diagnose.rs(FHE)
↓ compile(circom / halo2 / noir / arkworks)
2. CIRCUIT(共通の中間表現) y = (a × b) + c ゲートとワイヤ。低レベルの数学的表現
↓ run(prover / protocol / FHE eval)
3. EXECUTABLE(共通) end user には backend が裏で動くだけ
宿題ではここ: 課題は 2 の CIRCUIT を手で書く。「回路」が ZK / MPC / FHE の共通の土台だから、Week 1 に置かれている。
SumCheck(1992)→ GKR(2008)→ Groth16(2016)→ Spartan・Nova・Lasso・Jolt と、証明時間は 10 年で 2 桁以上速くなった。Google の Longfellow は既存の身分証規格の上で ZK を動かす。
Week 4 との接続: SumCheck・GKR・Groth16 が Week 4 の 3 部品表に出てくる。
スライド 33〜37 / ProgCrypto を使ったシステム開発
STEP 1 誰が STEP 2 何の情報を誰から隠して STEP 3 どういう計算をして STEP 4 誰が検証するか KelpDAO: off-chain の AI Auditor が/攻撃手順 W を事業者・第三者・他ユーザーから隠して/「W でコントラクトが壊れる」を計算し/on-chain コントラクトが検証(成功したら自動遮断)
Week 2・4 でも同じ問い: MPC の party / input / function / output、Week 4 の 5 パート(証明者・方法・対象・検証・検証者)。
要件が決まると、ZK か MPC か FHE か、対話型か非対話型か、が決まる。KelpDAO のパターン A は zkVM(RISC Zero)で「攻撃手順を実行したら壊れた」の実行を証明する。
ホワイトボード: KelpDAO 事件を防ぐシステムを 1 つ(アーキテクチャ図・要件・選定技術) 課題: ① 健全な回路を組む(許可された資格だけを通す制約) ② 壊れた回路を破る(制約の抜けを突く witness) ③ PR で提出
課題 README / proof-of-exploit
ここからが技術の実体です。README の言葉を 1 つずつ、中学の方程式から橋を架けて読みます。
信号(signal) 回路に出てくる変数。値は有限体 F_p の元(大きな素数の余りの世界) 制約(constraint) 「ある式が 0 になる」という条件。 例: b·b − b = 0 は「b は 0 か 1」 witness 全部の信号に値を入れた割当。全制約を満たす witness があれば「証明できた」 中学: 「x + 3 = 5」を移項して「x + 3 − 5 = 0」。右辺を 0 にそろえるのが制約の書き方
宿題ではここ: cs.input(name, value) が入力の信号、cs.aux(name, value) が補助信号、cs.assert_zero(expr) が制約、cs.set_output(granted) が出力。
本当は条件を満たしていない入力で制約を満たす witness が作れてしまうと、回路は「嘘の証明」を受理する。防げていれば健全。信号は、制約で縛らない限り好きな値にできる。だから必要な制約を書き忘れると健全性が壊れる(アンダー制約)。
Q. 「制約を 1 本忘れる」と「制約を 1 本多く書く」、危ないのはどっちか。(忘れる方。多い方は正直な人が通らなくなる=完全性の問題で、嘘は通さない)
資格 (role, clearance, region) は各 0〜7。 ROLE_OK = {2, 5, 6}, CLEARANCE_OK = {3, 4, 7}, REGION_OK = {1, 6}
authorized ⇔ role ∈ ROLE_OK かつ clearance ∈ CLEARANCE_OK かつ region ∈ REGION_OK (通る組は 3 × 3 × 2 = 18 通り/512 通り)
作るもの: granted == 1 にできる ⇔ その資格が authorized
b·b − b = b·(b − 1) = 0 ⇔ b = 0 または b = 1 (中 3 の因数分解: 積が 0 なら、どちらかが 0) b = 2 なら 4 − 2 = 2 ≠ 0 → 通らない。 b = 1 なら 0 ✓、b = 0 なら 0 ✓
宿題ではここ: cs.assert_zero(f * f - f) を f_role, f_clearance, f_region, granted の 4 本。「フラグ」を 0/1 に縛る。
(x − 2)(x − 5)(x − 6) = 0 ⇔ x ∈ {2, 5, 6} (因数分解の解。x = 3 なら (1)(−2)(−3) = 6 ≠ 0、x = 5 なら 0)
前に f を掛ける: f·(x − 2)(x − 5)(x − 6) = 0 ⇔ f = 0 または x ∈ {2, 5, 6}
→ 「f = 1 なら x はリストの中」。f = 0 のときは x に何も言わない(それでよい: granted は f の AND だから)
宿題ではここ: _membership_product(x, allowlist) が積を作り、cs.assert_zero(f_role * _membership_product(r, ROLE_OK)) など 3 本。この 3 本のどれかを忘れるのが Part B の穴。
3 つが全部 1 のときだけ積が 1、どれか 0 なら 0。 制約: granted − f_role·f_clearance·f_region = 0 (Week 2 の Boolean 回路の AND、Week 4 の PLONK の掛け算ゲートと同じもの)
宿題ではここ: cs.assert_zero(granted - f_role * f_clearance * f_region)。これで build は 8 本の制約(ビット 4 + リンク 3 + AND 1)。
tests/challenge.py の制約: ビット 4 本 ✓、role のリンク ✓、clearance のリンク ✓、region のリンク ✗(無い)、AND ✓
→ f_region は「1 なのに region が {1, 6} の外」という値を取れる。region の値そのものに触れる式が 1 本も無い
attack(): honest_witness(2, 3, 1) から始めて region = 0, f_region = 1, granted = 1 に書き換える
検算: ビット制約 4 本 → 0 ✓、role リンク f·(2−2)(…) = 0 ✓、clearance リンク f·(3−3)(…) = 0 ✓、AND 1 − 1·1·1 = 0 ✓
region = 0 は REGION_OK に無いのに granted = 1 → 本来入れない人が通った
宿題ではここ: attack() は全信号名 → 値の dict を返す。「どの信号に触れている式が無いか」を探すのが解き方。
完全性: authorized な 18 通りは全部 granted = 1 で通るか (制約が多すぎると落ちる) 構造: 制約の集合が入力値に依存しないか (if で制約を出し分けると落ちる) 健全性: authorized でない 494 通りで granted = 1 にできないか (solver が攻撃者として探す。制約が足りないと落ちる) exploit: attack() が challenge を破っているか
覚える 1 点: 完全性は「通すべき人を通す」、健全性は「通してはいけない人を通さない」。Week 3・4 でも同じ 2 語が主役。
やってみる — 紙と鉛筆で。答えは開くまで隠れる
b=0: 0、b=1: 0、b=2: 2、b=3: 6。0 になるのは 0 と 1 だけ → 「ビット」の制約になっている。
x=2: 0·(−3)·(−4) = 0、x=4: 2·(−1)·(−2) = 4、x=6: 4·1·0 = 0。リストの中(2, 6)だけ 0。
x=4: 積が 4 なので f·4 = 0 → f = 0 だけ(F_p では 4 に逆元があるので f = 0 が唯一)。 x=5: 積が 0 なので f は 0 でも 1 でもよい(ビット制約が別にある)。 → 「リストの外なら f は 0 に固定、中なら自由」。granted = 1 にするには f = 1 が要るので、外の人は通れない。
積なので 0 と 1。3 つ全部 1 のときだけ 1(AND)。
clearance のリンク f_c·Π(clearance − CLEARANCE_OK) が無い。 → 攻撃: honest_witness(2, 3, 1) から clearance = 0, f_clearance = 1, granted = 1 に書き換えると全制約が通る。
制約は verifier が事前に固定して持つ「型」。prover の入力に合わせて式を出し分けられるなら、prover が都合のよい式を選べてしまう。 Week 4 の PLONK で「セレクター(回路の型)は公開、witness は秘密」と分けたのと同じ線引き。
宿題の 2 関数
| 関数 / 部品 | スライド・README | 学校で習ったこと | 小さい数の例 | 落ちやすい所 |
|---|---|---|---|---|
| build: ビット制約 | README「制約」 | 因数分解 b(b−1) = 0(中 3) | b=2 → 2 ≠ 0 | 4 本(granted も) |
| build: メンバーシップ | README「考え方」 | 因数分解の解 (x−a)(x−b)… = 0(中 3) | x=4 → 4、x=5 → 0 | 3 本のうち 1 本忘れる |
| build: AND | スライド 28 の × ゲート | 掛け算 = 全部 1 のときだけ 1 | 1·1·0 = 0 | — |
| build: 構造 | 採点 2 | — | — | if で制約を出し分けない |
| attack | README Part B | 「式が触れていない変数は自由」 | region = 0, f_region = 1 | honest_witness から書き換える |
提出前の自己チェック — 5 問、自分の言葉で
(1) 「回路」とは何か、1 文で。 (2) b·b − b = 0 がビットを表す理由。 (3) f·(x−2)(x−5)(x−6) = 0 で f = 0 のとき x に何も言えない理由と、それで困らない理由。 (4) 制約が 1 本抜けると何が起きるか、具体的な witness で。 (5) 完全性と健全性の違いを、この課題の採点で言うと。
用語集
| 用語 | 意味 | なぜその言葉か | 宿題では |
|---|---|---|---|
| Programmable Cryptography | ZK / MPC / FHE などの総称。「何を計算するか」をプログラムのように書ける暗号 | 0xPARC の造語。programmable = 書ける | — |
| 回路(circuit) | + と × のゲートとワイヤで書いた計算。= 「0 になるべき式の束」 | 電気回路の比喩(ゲート・ワイヤ)。ZK / MPC / FHE 共通の中間表現 | build が組むもの |
| 信号(signal)/ワイヤ | 回路の変数。値は F_p の元 | 回路の線に乗る値 | cs.input / cs.aux |
| 制約(constraint) | 「この式は 0」という条件 | constrain = 縛る。中学の方程式を移項したもの | cs.assert_zero |
| witness | 全信号への値の割当。全制約を満たせば「証明できた」 | 法廷の「証人」。見せれば真だと分かる値 | attack() が返す dict |
| aux(補助信号) | 入力でも出力でもない中間の信号(フラグなど) | auxiliary = 補助の | f_role など |
| 完全性(completeness) | 正しい入力は必ず通る | complete = 取りこぼしがない | 採点 1 |
| 健全性(soundness) | 正しくない入力は通らない | sound = 確かな | 採点 3(solver) |
| アンダー制約(under-constrained) | 縛る式が足りず、嘘の値が通る状態 | under = 不足 | challenge.py の穴 |
| メンバーシップ | 「x はリストの中」の制約 | membership = 所属 | f·Π(x − a) = 0 |
| Proof-of-Exploit | 「この手順で壊れる」ことを、手順を見せずに証明 | exploit = 脆弱性を突く手順 | 課題名 |
| circuit breaker | 異常を検知したら自動で止める仕組み | 電気のブレーカー | KelpDAO の例 |
| ZK / MPC / FHE | 見せずに証明/集めずに計算/暗号化したまま計算 | Zero-Knowledge / Multi-Party Computation / Fully Homomorphic Encryption | Week 3・2・5 |
| zkVM | プログラムの実行そのものを ZK で証明する仮想マシン | RISC Zero、SP1 など | スライド 35 |
| DSL | 回路を書くための小さな専用言語 | Domain-Specific Language。circom / noir など。課題の aclib はトイ DSL | tests/aclib.py |
| F_p(有限体) | 素数 p で割った余りの世界。信号の値の住処 | 土台編 | 信号の値 |
ラボ
左のパネルで prover の主張(witness)を組み立て、右の台帳で 8 本の式が 0 になるかを見る。 各行のスイッチを切ると、その 1 本を書き忘れた回路になり、 下の採点器が「その回路なら何通りの不正資格が通るか」をブラウザ内で総当たりして数える。
いま有効な制約だけを使い、権限のない 494 通りの資格それぞれについて
「フラグをどう置けば granted = 1 のまま全制約を満たせるか」を総当たりする。
1 つでも見つかれば、その回路は嘘の証明を受理する。
見つかった反例(押すと witness に読み込む)
この探索器はフラグを 0/1 に限って総当たりする簡易版。本物の tests/solver.py は
ビットを場合分けしつつ、残りを F_p 上の連立一次方程式として解く。
結論は今回の回路では一致することを確認済み。
実験 1
0 に、f_region を 1 のままにする
起きること: 全制約を満たしたまま granted = 1。
これが tests/challenge.py の状態そのもので、Part B の答えでもある。
実験 2
読み取ること: 穴の大きさは「その 1 本が守っていた自由度」で決まる。 AND を落とすと 3 フラグ全部が無意味になるため、全滅する。
実験 3
ただし結論を急がない: 「反例が出ない」と「安全である」は別のこと。 理由は次の節で。
理解度チェック
読んだだけでは定着しない。ここではあなたが検証者の席に座り、 提出された witness と回路を審査する。問題は毎回その場で生成され、 判定は上のラボとまったく同じ計算器が行う。3 回誤判定したら、その回は終了。
検問モードを選ぶ
誤答するとその場で解説が出る。分野を絞って弱点だけ潰すこともできる。
この先
今日の 7 信号・8 制約は、R1CS の一歩手前の形。
中間信号を切って次数 2 に割れば、そのまま Circom が吐く R1CS になる
(今回のメンバーシップリンクは次数 4、AND は次数 3 なので、まだ R1CS そのものではない)。
aclib.py が使う素数 p は BN254 の scalar field で、実際の Groth16 / PLONK でそのまま出てくる数。
cs.input / cs.aux / cs.assert_zero は、Circom の
signal input / signal / === にほぼ対応する
(Circom の === は次数 2 までしか受け付けない点だけ違う)。
ZK・MPC・FHE はどれも「計算を算術回路に翻訳してから暗号をかける」構造を共有している。 だから 回路が間違っていれば、その上の暗号がどれだけ正しくても意味がない。 実際、ZK プロジェクトの脆弱性報告の多くは暗号の破れではなく、今日と同じアンダー制約だ。
持ち帰る問いは 1 つ。「この信号を縛っている式は、どれか」。 答えられない信号が 1 本でもあれば、そこが次の穴になる。
付録
上のカード列で骨組みが入ったあとで、細部を足したいときに開いてください。なぜ + と × だけなのか、なぜ回路にするとプライバシーが守れるのか、leave-one-out で「どの制約が効いていたか」の実測、Circom / Noir での同じバグ、はここにあります。
全体像
Week 1 だけ見ると「なぜ 8 本の式をいじっているのか」が分からない。先に地図を置く。
ゼロ知識証明の理論と実装の理解を促し、Ethereum エコシステムや実社会に コミットする技術者を養成する。
ゴールは知識ではなく成果物。成果物テーマ案として、実際の企業が出した課題 (Intmax・Nyx Foundation・SMBC日興証券・キリフダ ほか)が並んでいる。 MPC ウォレットの鍵復元、プライバシー決済の匿名性、規制産業での機密 LLM、 AI エージェントの行動証跡——どれも「見せずに正しさを示す」という同じ形をしている。
このプログラムには一貫した型がある。自分で作り、自分で壊す。 Week 1 は回路を組んでその穴を突く。Week 3 は Schnorr 署名を作って nonce の使い回しから鍵を抜く。 Week 6 は zkVM の中で動く exploit を書く。
攻撃側に立たないと、何を守っていたのかが分からない——という思想で組まれている。
だから Week 1 の課題名は proof-of-exploit(脆弱性の証明)になっている。
週の地図
| 週 | 講義(スライドの 7 週間カリキュラム) | 課題(リポジトリ) |
|---|---|---|
| 0 | 有限体と楕円曲線(事前学習スライド) | — |
| 1 | Programmable Cryptography 概要 | proof-of-exploit ← 今ここ |
| 2 | MPC 秘密計算 | 未公開 |
| 3 | ZKP・SNARK / STARK | schnorr-from-scratch |
| 4 | FHE・格子暗号 / LWE | 未公開 |
| 5 | Advanced(vFHE / zkVM) | tfhe-toy-python |
| 6–7 | プロジェクト/Demo Day | co-snark-prove/zkvm-exploit |
毎週土曜に講義 2 時間 + ホワイトボードセッション 2.5 時間、日〜木で自習・実装、金曜に課題提出という周期。 Week 3 の課題は有限体 → 楕円曲線 → シグマプロトコル → Fiat-Shamir と積み上げる構成で、 講義の「ZKP・SNARK / STARK」の土台にあたる。 Week 5 だけ講義(vFHE / zkVM)と課題(TFHE)がずれて見えるので、 進行に合わせて確認する。
Week 1 が最初に来る理由
ZK も MPC も FHE も、計算を「算術回路」に翻訳してから暗号をかける。回路は 3 つの共通言語。
つまり 回路が間違っていれば、その上にどんな暗号を積んでも意味がない。 Week 1 が暗号を一切使わず、Python の小さなライブラリだけで回路を扱うのは、そこが土台だから。
そして今週学ぶ「制約の書き忘れ(アンダー制約)」は、実務でそのまま使える。 成果物テーマにも Proof-of-Exploit による DeFi 自動停止、 ZK/FHE/MPC 回路のバグ発見、形式証明された ZK 回路の実装が並んでいる。 Week 1 でやることは、その入口そのもの。
このページの構成
出発点
ここが腑に落ちていないと、以降の制約がただの奇妙な数式に見えてしまう。 「そもそも何がしたくて、なぜ普通のコードでは駄目なのか」から順に片付ける。
今回の題材で言えば、こういう主張を通したい。
「私はこのシステムにアクセスする権限を持っている。
ただし、自分の役職も、クリアランスも、所属地域も一切明かさない。」
年齢確認なら「18 歳以上である。ただし生年月日は見せない」。 送金なら「残高は足りている。ただし残高は見せない」。 結論だけを、根拠を隠したまま、相手に確信させたい——これが共通の願い。
| 案 | やること | 何が起きるか |
|---|---|---|
| ① 自己申告 | 「権限あります」と送る | 嘘がつける。検証が存在しない |
| ② 資格を全部見せる | role/clearance/region を送る | 通るが、隠したかった情報が全部漏れる |
| ③ サーバに預けて判定してもらう | 暗号化して送り、向こうで復号 | サーバを全面的に信用することになる。漏洩・内部犯行に無防備 |
| ④ 信頼できる第三者に証明書を出させる | 「権限あり」と署名してもらう | その第三者は全員の資格を知っている。信頼の一点集中が残る |
欲しいのは「本人にしか作れず、中身を明かさず、相手が数学的に確認できる証拠」。 誰かを信用する話に落とさずに、これを作る道具が ZK(ゼロ知識証明)であり、 同じ土台を共有するのが MPC(複数人で分担計算)と FHE(暗号化したまま計算)。
FHE なら暗号文のまま E(a) + E(b) = E(a+b)、E(a) × E(b) = E(a×b) ができる。
MPC なら秘密分散されたシェアどうしで加算・乗算ができる。
ZK の証明系が扱うのは多項式で、多項式を作る操作もやはり加算と乗算。
共通点: どの技術も「+ と ×」だけを提供する。 比較・分岐・ループ・配列アクセスといった、普段プログラムを書くときの道具は1 つも入っていない。
この翻訳結果が算術回路。プログラムを、加算と乗算だけで組み立てた 巨大な式の集まりに書き直したものだ。
なぜ「回路」と呼ぶのか: 論理回路が AND/OR/NOT ゲートの組み合わせであるように、 こちらは加算ゲートと乗算ゲートの組み合わせだから。ただし電気は流れないし、 実行順序という概念もない。全部の条件が同時に成り立っているかどうかだけが問われる。
if s: out = a else: out = b は、回路では
out = s·a + (1−s)·b と書く(s は 0 か 1)。
どちらの枝も必ず計算され、選択子で片方を消す。
なぜこうなるのか: ① 値が見えないので、どちらに進むか判断できない。 ② かりに判断できても、どちらに進んだかという事実自体が秘密を漏らす。 実行時間や回路の形が入力によって変われば、そこから中身が推測できてしまう。 だから回路は常に全経路を計算する。後の「構造を入力値に依存させるな」という要求は、ここに根がある。
verifier ができるのは、突き詰めれば「この等式は成り立っているか」の確認だけ。 だから「x はリストに入っている」「フラグは 0 か 1」といった条件を、 全部「ある式の値が 0」という同じ形に書き直す。
揃えると何が嬉しいか: 形が同じなら、何千本もの条件をまとめて 1 本の多項式に畳み込み、 ランダムな 1 点で評価するだけで全部を一括検査できる。 ZK の効率はこの「形の統一」に支えられている。 true/false という型が存在しないのは、手抜きではなく設計。
普通のプログラムは、入力から答えを計算して返す。 回路は違う。prover が答えを先に主張し、回路はその主張が嘘でないことだけを確かめる。
分かりやすい例が割り算だ。回路に除算ゲートは無い。ではどうするか——
prover に「1/x の答えは y です」と言わせる。
回路が書く制約はたった 1 本、x · y − 1 = 0。
これで y が本当に逆数であることが保証される。
難しい計算は prover にやらせ、verifier は掛け算 1 回で検算する。
今回のフラグもまったく同じ発想でできている。
「role が許可リストに入っているか」を回路が計算するのではない。
prover が f_role = 1 と主張し、
回路は「その主張が嘘なら 0 にならない式」を 1 本置くだけ。
同じ判定を、2 つの世界で書くと
# 普通のプログラム — 計算して決める if role in {2, 5, 6}: f_role = 1 else: f_role = 0 # 回路 — prover が主張し、嘘を禁じる # f_role の値は prover が勝手に決めてよい # 回路が言えるのは「1 と言うなら嘘は許さない」だけ f_role * f_role - f_role == 0 f_role * (role-2)*(role-5)*(role-6) == 0
上は手続き、下は条件。下には「f_role を 1 にせよ」と命じる行がどこにも無いことに注目。 値を入れるのは prover の自由で、回路にできるのは禁止だけ。
ここから、回路に固有のバグが生まれる
主張ベースの設計なので、「その主張が嘘でないことの確認」を書き忘れると、嘘がそのまま通る。
普通のプログラムなら、計算を書き忘れれば動かない。すぐバグとして表に出る。 しかし回路では、確認を書き忘れても正直に使っている限り完璧に正常動作する。 テストも通る。壊れていることに誰も気づかない。
これがアンダー制約で、Week 1 の主題であり、 現実の ZK プロジェクトで報告される脆弱性の最頻出パターンでもある。 以降のページは全部、この 1 種類のバグを見る目を作るためにある。
動機
proof-of-exploit という名前なのかWeek 1 の講義は、ある事件を題材にしています。課題名はそこから来ていて、 今週書くコードは講義の主題そのものです。
講義では KelpDAO のブリッジが攻撃された事件(想定シナリオ)を扱います。 この種の攻撃で厄介なのは、発見から実行までが分単位だという点です。 人間のガバナンス投票では間に合いません。
では自動で止めればいいかというと、そう簡単でもない。 誤検知で勝手に止められるリスクがあるからです。 「危ないと思ったので止めました」を無条件に信じる仕組みは、それ自体が攻撃対象になります。
講義が示す答えが Proof-of-Exploit × Circuit-Breaker です。
off-chain の AI Auditor が攻撃手順を見つけたら、その手順を誰にも見せないまま 「この手順でコントラクトが壊れる」ことを証明する。on-chain の検証が通ったときだけ、自動で止める。
止める権限を、人の判断ではなく検証可能な証拠に紐づける。 これがこの週のいちばん大きな考え方です。
仕組みを 1 枚で
攻撃手順 W を発見(AI Auditor / off-chain)
│
│ W は見せない
▼
「W を入れるとこの契約は壊れる」
という証明だけを作る
│
▼
on-chain のコントラクトが検証
│
├─ 通った → 自動で遮断
└─ 通らない → 何も起きない
W を公開してしまうと、それを見た誰かが先に攻撃できます。 だから隠したまま、壊れることだけを示す必要がある。 ゼロ知識証明がここで効く理由がこれです。
今週の課題との対応
Part B で書く attack() は、この「W」を素手で作る練習。
回路の穴を突く witness を見つけ、それが本当に全制約を満たすことを示す—— スケールは小さいですが、やっていることは Proof-of-Exploit と同じ形です。 そして Part A は、そもそも穴を作らない側の練習になります。
成果物テーマにも「AI エージェント × Proof-of-Exploit による DeFi 自動停止」と 「SPECA を使った ZK / FHE / MPC 回路のバグ発見」が並んでいます。 今週の 8 本の式は、その入口ではなく縮小版です。
核心
ここが一番よく分からないところ。先に結論から言うと——
先に、今週作るものを 1 分で
この節の話をするために、完成形を先に見せます。詳しくは後の節でやるので、形だけ眺めてください。
作るのは信号が 7 本、「0 になるべき式」が 8 本の小さな回路です。
資格 3 本(role / clearance / region)、フラグ 3 本、出力 granted が 1 本。
検証の手続きはこれだけ —— 7 本すべてに値を入れて、8 本の式が全部 0 になるか確かめる。 この「7 本の値の組」を witness と呼びます(用語は次の次の節でまとめます)。
# 信号 7 本 role clearance region ← 資格 f_role f_clearance f_region ← フラグ granted ← 出力 # 0 になるべき式 8 本(抜粋) f_role² − f_role = 0 f_role · (role−2)(role−5)(role−6) = 0 granted − f_role·f_clearance·f_region = 0
そのうえで、誤解を外す
回路そのものは、プライバシーを守らない。
いま見たとおり、この検証は 7 本の値をまるごと検証側に渡しています。 role も clearance も丸見えで、隠れているものは何一つありません。 「回路を書いた=秘密が守られた」ではない、ということです。
プライバシーを作るのは、その上に乗る暗号のほう。 ただしその暗号は「等式が成り立つかを確かめる」ことしかできない。 だから回路の役割は、確かめたいことを全部「等式」に翻訳しておくこと—— 暗号をかけられる形に、計算をあらかじめ整えておく係だ。
まず、値ごとに印を作る道具があるとします。ルールは 2 つだけ。
① 印を見ても、元の値は分からない。 ② 一度つけた印に合う値は、あとから変えられない。
両方そろって初めて意味があります: ①だけなら、あとから「実は違う値でした」と嘘をつけます。 ②だけなら、中身が丸見えです。「隠したまま、逃げられなくする」のがこの道具の役割。
いちばん簡単な作り方は「2 を値の回数だけ掛ける」です。実際に数字でやってみます。
3 の印 = 2³ = 8 4 の印 = 2⁴ = 16 8 × 16 = 128 = 2⁷ = 7 の印
印を掛けただけなのに、中身は足し算されました(3 + 4 = 7)。
指数法則 2³ × 2⁴ = 2³⁺⁴ がそのまま効いているだけで、
誰かが「足し算機能」を作ったわけではありません。
「8 を見たら 2³ だと分かるのでは」——小さい数ならそのとおりです。 だから本物は巨大な素数の世界でやります。そこでは印から値を逆算するのが現実的に不可能で、 これが Week 0 で出てきた離散対数問題です。
誰かが「x + y = z です」と主張したとします。確かめる側がやることは——
x の印 × y の印 と z の印 が一致するかを見るだけ
一致すれば主張は正しい。しなければ嘘。x も y も z も、値は 1 つも分かっていません。
ここがプライバシーの正体です。 検証という行為が「値を読んで判定する」から 「印を比べる」に変わりました。読んでいないので、漏れようがない。 そして比べられるのは「同じか違うか」だけで、「どちらが大きいか」は比べられません。 だから、確かめたいことを全部等式に書き直しておく必要があります。
箱の外から言えるのは「この箱とこの箱は同じ中身か」まで。 「x のほうが大きいか」は言えない(言えたら箱が壊れている——次の節で説明する)。
だから回路が要る: 「権限がある」という言いたいことを、 足し算と掛け算と等号だけで書き直す。その翻訳結果が算術回路。 回路は暗号を「かけられる形」を用意する工程であって、隠す仕事はしていない。
今週の回路は式が 8 本なので全部見ればいい。でも本物の回路は制約が 100 万本を超えます。 1 本ずつ確かめていたら、検証が計算そのものと同じくらい重くなってしまう。
欲しいのは: 100 万本まとめて、ほんの数回の計算で「全部 0 だ」と確かめる方法。 これができないと、そもそも実用になりません。
8 本の制約に値を入れた結果を c₀, c₁, …, c₇ と呼びます。
全部 0 であってほしい数です。ここで検証者がでたらめな数 r を 1 つ選び、次の式を作ります。
S = c₀ + c₁·r + c₂·r² + c₃·r³ + … + c₇·r⁷
順に読みます。 c₀ はそのまま、c₁ には r を 1 回、c₂ には r を 2 回……と掛けて、全部足す。それだけです。
正直な場合: c₀ から c₇ まで全部 0 なら、どの項も「0 × 何か」なので 0。
足しても S = 0 です。必ず 0 になります。
嘘の場合: たとえば c₆ だけ 0 でないとします。すると S は
c₆·r⁶ の項が残るので、r の式として 0 ではありません。
ここが肝心: 0 でない式でも、特定の r を入れれば偶然 0 になることはあります。
しかし r⁷ までしか出てこない式が 0 になる r は、多くても 7 個しかありません
(2 次方程式の解が最大 2 個、3 次なら 3 個、と同じ話)。
一方 r の候補は p ≈ 2²⁵⁴ 個あります。
偶然 0 を引く確率 ≈ 7 ÷ 2²⁵⁴ —— 事実上ゼロです。
検証者がやることは S を 1 個受け取って S = 0 かを見るだけ。
制約が 8 本でも 100 万本でも変わりません。
そして S を渡すときに前の節の「印」を使えば、witness そのものは見せずに済みます。 しかも S には乱数が混ぜてあるので、S から元の値をたどることもできません。
これで 2 つが同時に成立します。
隠せる —— 渡すのは印と 1 個の数だけで、witness は渡さない。
騙せない —— 制約が 1 本でも破れていれば、r をどう引いても S はほぼ確実に 0 にならない。
頭文字を 1 つずつ開くと、そのまま性質の説明になっています。
Succinct / Non-interactive / ARgument of Knowledge
Succinct(簡潔) = 回路が巨大でも証明は数百バイトで済む。いま見た「1 個の数に畳む」がこれ。
Non-interactive(非対話) = 証明を 1 回送れば終わり。何度もやりとりする必要がない。
Argument of Knowledge(知識の主張) = 「答えを知っている」ことの主張である。
Groth16 や PLONK は、この SNARK の具体的な作り方の名前です。 Week 1 では一切使いませんが、そこへ渡すために回路を用意しているという関係になります。
| 層 | 担当 | 今週やること |
|---|---|---|
| 算術回路 | 言いたいことを等式に翻訳する | これ(暗号は使わない) |
| コミットメント | 値を隠しつつ、後から変えられなくする | — |
| 証明系 | 全制約をまとめて 1 点で検査する | — |
下の 2 層があってもいちばん上が間違っていれば、「嘘の主張に対する完璧な証明」ができあがる。 Week 1 が土台と呼ばれるのはそういう意味。
三兄弟
いつも 3 つ並べて語られるので混ざりやすい。だが解いている問題はまったく別物で、 共通しているのは道具立てのほうだけ。まず言葉の意味から。
| ZK ゼロ知識証明 | MPC 秘密計算 | FHE 準同型暗号 | |
|---|---|---|---|
| 解く問題 | 本当に正しく計算したか | 自分の値を他人に見せたくない | 計算は任せたいが中身は渡したくない |
| 秘密を持つ人 | 1 人(prover) | 参加者それぞれ | 依頼者 1 人 |
| 計算する人 | prover 自身 | 参加者全員で分担 | 受託者(クラウド) |
| 相手に渡るもの | 証明(SNARK 系なら数百バイト) | 計算結果だけ | 暗号化された結果 |
| 相手が学ぶこと | 主張が正しいという事実のみ | 結果のみ(他人の入力は不明) | 何も学ばない |
| 信頼前提 | prover を信頼しなくてよい(数学が保証) | n 人中 k 人を信頼(不正が t 人未満なら安全) | 利用者の鍵管理が信頼の起点 |
| 敵対モデル | prover の偽証/verifier による witness 窃取 | 参加者の逸脱(最大 t 名・準正直/悪意) | 盗聴者/サーバ(計算の正しさは別途 = vFHE) |
| 典型例 | 残高を見せずに送金の正当性を示す | 各社の給与を明かさず平均だけ出す | 暗号化した医療データをクラウドで解析 |
ZK で使うと
資格を持っているのは本人。granted = 1 を満たす witness を知っていることの証明を作り、
サーバに送る。サーバは role も clearance も region も永遠に知らないまま、
アクセスを許可する。
MPC で使うと
人事部が role、セキュリティ部が clearance、拠点管理が region を持つ。
誰も他部署の値を知らないまま、3 者で判定を計算して granted を得る。
主張する人がいないので、証明は登場しない。
FHE で使うと
資格を暗号化してクラウドに送る。クラウドは暗号文のまま判定を計算し、
暗号化された granted を返す。復号できるのは依頼者だけ。
ここにも証明は無い。
選ぶときの軸 — 「誰を信頼しないで済むか」
3 方式の本当の違いは、隠し方ではなく信頼の置きどころ。
ZK は相手を信頼しなくていい(嘘は数学が弾く)。 MPC は全員は信頼しなくていいが、一定数は信頼する(t 名未満の逸脱なら安全)。 FHE は計算する相手にデータの機密は預けないが、鍵の管理は自分の責任で、 しかも計算が正しく行われた保証は別物(そこを埋めるのが vFHE)。
要件が「相手が嘘をつくかもしれない」なら ZK、「複数人の秘密を突き合わせたい」なら MPC、 「計算を外に出したい」なら FHE。隠したいものではなく、疑いたい相手から決まる。
① SOURCE — 用途ごとに書く ZK : 年齢証明 age_check(dob, today) MPC : 秘密入札 auction(b1, b2, b3) FHE : AI 診断 diagnose(data) │ │ compile(circom / halo2 / noir / arkworks) ▼ ② CIRCUIT — 共通の中間表現 y = (a × b) + c gates と wires だけの低レベル表現 │ ▼ ③ EXECUTABLE — backend が裏で動く ZK prover / MPC protocol / FHE eval
上と下は用途ごとに違いますが、真ん中の「回路」は共通です。 書く言語も、最後に走る暗号も入れ替わるのに、間にある表現だけは同じ。
Week 1 でやっているのは、この真ん中の層を素手で組む練習です。 暗号を一切使わないのは手抜きではなく、そこが 3 方式の交差点だから。
そして交差点が間違っていれば、上に何を載せても間違ったまま動きます。
だから、回路は ZK 固有の産物ではない
3 方式とも、同じ判定ロジック(メンバーシップと AND)を算術回路にする。
ただし組み方の形が違う。ZK は検証の形—— フラグは prover が主張し、回路は「その主張が嘘なら 0 にならない式」を置くだけ(これが今週の 8 本)。 MPC と FHE は計算の形——主張してくれる人がいないので、 フラグ自体を「メンバーシップ積が 0 か」から計算しなければならない。
それでも「加算と乗算だけの式に翻訳する」という土台は完全に共通で、 そこだけを扱う Week 1 が「暗号を一切使わない」のはそういう理由。
語彙
検証の手続き — たった 2 行
① prover が witness を出す ② verifier が 8 本の式に代入して、全部 0 かを見る
本物の ZK では ② を「witness を見ずに」やるための暗号がかぶさるが、 判定内容そのものは何も変わらない。だから回路が間違っていれば、上にどんな暗号を載せても無駄。 Week 1 が暗号抜きで回路だけを扱うのは、そこが土台だから。
前提
Week 0 のスライド「有限体と楕円曲線」で通った道。今回使うのは、そのうち 3 つの性質だけ。
事前学習(Week 0)との接続
| Week 0 で出てきたもの | 今週どこで効くか |
|---|---|
| 群 ⊃ 環 ⊃ 体 の包含関係 「環 = 加法と乗法」「体 = 環 + 乗法逆元」 | 「+ と × しかない」の根拠そのもの。環の定義に演算が 2 つしか無いことが、この講座全体の制約になる |
| mod 演算・時計の比喩(Z₁₃) | p 回足すと 0 に戻る = 順序が存在しない理由。だから比較が書けない |
| 加法逆元・乗法逆元 | 引き算・割り算ができる根拠。採点器 solver.py は pow(a, P−2, P)(フェルマーの小定理)で逆元を取っている |
| 位数(要素の個数) | 今週の p は BN254 の scalar field、位数は約 2²⁵⁴ |
| 離散対数問題 | コミットメント g^x を開けられない根拠。前の節の「箱」が箱でいられるのはこれのおかげ |
| 有限体上の楕円曲線/演習の拡張ユークリッド法 | Week 3 の schnorr-from-scratch でそのまま実装する |
Week 0 が「数学の準備」に見えて実は伏線だった、という構造。 とくに群・環・体の図は、今週の「なぜ + と × だけなのか」への直接の答えになっている。
p = 21888242871839275222246405745257275088548364400416034343698204186575808495617(BN254 の scalar field)。
足し算も掛け算も、やったあとに p で割った余りを取る。数がいくら大きくなっても、この 1 本の帯の中に収まる。
なぜ余りの世界なのか: 暗号は「決まった大きさの数」を扱う必要があるから。 実数のように無限に伸びる値は、暗号の道具に載せられない。
p が素数なので、0 以外のどの元にも逆数がある。負の数も普通に扱える
(−6 は p − 6 と同じもの)。
実務上の注意: 一方で「大小比較」と「範囲」は自然には存在しない。
x < 8 のような条件は、それ専用の制約(range proof)を書かないと表現できない。
今回は許可リストが値を 2〜3 個に固定するので、範囲の心配が要らない設計になっている。
主張はこれです。a · b = 0 なら、a = 0 か b = 0 の
どちらかが必ず成り立つ。「どちらも 0 でないのに掛けたら 0」は起きません。
これは「整数と同じ感覚だから」で済ませず、さっきの ②(逆数がある)から 4 行で出ます。 覚えるものではなく、導けるものです。
仮定: a · b = 0 で、しかも a ≠ 0 だとする
① a ≠ 0 なので、逆数 a⁻¹ が存在する (体の定義そのもの)
② 両辺に a⁻¹ を掛ける a⁻¹ · (a · b) = a⁻¹ · 0
③ 左辺を組み替える (a⁻¹ · a) · b = 0 → 1 · b = 0
④ よって b = 0
つまり 「a ≠ 0 なら、b は 0 でしかありえない」。 対偶を取れば「a·b = 0 なら a = 0 または b = 0」で、主張のとおりです。
どこで「体であること」を使ったか: ① だけです。 0 以外に逆数がある、という一点から零因子の不在が出ます。 逆に言えば、逆数が無い世界ではこれは成り立ちません (例: 時計算 mod 12 では 3 × 4 = 12 ≡ 0。3 も 4 も 0 でないのに積が 0 になる。 12 は素数でないので体ではなく、3 に逆数が無いのが原因です)。 p が素数でなければならない理由が、ここに出ています。
そして今回の回路がこの上に乗ります: f · (何かの積) = 0 と書いておけば、
f ≠ 0 と分かった瞬間、いま証明したことから積の側が 0 だと断言できる。
許可リストの強制も、ビット制約も、まるごとこの 4 行の帰結です。
原理
制限があるから 2 つに絞っている、のではない。2 つしか存在しないから 2 つ、という話。
その前に —— 準同型とは何か、身近な例で
準同型とは「先に計算してから変換」と「変換してから計算」が一致する変換のこと。
f(a ○ b) = f(a) ● f(b)
左辺は「a と b を先に計算してから f で変換」、右辺は「a と b をそれぞれ f で変換してから計算」。 この 2 つが必ず同じ答えになるとき、f を準同型と呼びます。それだけです。
例 1 — 対数(log)
log(a × b) = log(a) + log(b)
掛け算の世界を、足し算の世界に移しています。
昔の計算尺や対数表が便利だったのは、これのおかげでした。2.34 × 5.67 を筆算する代わりに、
log を引いて足し算して戻せばいい。難しい演算を、簡単な演算に移せる ——
これが準同型の「だから何」の第一の答えです。
例 2 — 偶数か奇数か
3(奇)+ 5(奇)= 8(偶) 奇 + 奇 = 偶
整数を「偶か奇か」に潰す変換も準同型です。 元の数を知らなくても、偶奇さえ分かれば和の偶奇が分かる。 情報をほとんど捨てているのに、足し算の構造だけは生き残っています。 これが「隠しても計算できる」の原型です。
例 3 — 時計(mod 12)
7 時 + 8 時間 = 15 → 3 時
整数の足し算が、時計の文字盤の足し算にそのまま対応します。Week 0 の mod 演算がこれでした。
では暗号は何をしているか
2³ × 2⁴ = 2³⁺⁴ = 2⁷
「2 を x 回掛ける」という変換 x ↦ 2ˣ は、log とちょうど逆向きの準同型です。
log が掛け算を足し算に移すのに対して、こちらは足し算を掛け算に移す。
だから前の節で 8 × 16 = 128 と印を掛けただけで、中身が 3 + 4 = 7 になりました。
暗号がやっているのは、この性質を一つ借りているだけです。 新しい機能を発明したのではなく、もともとある構造をそのまま壁の向こうへ持ち越している。
そして「比較が運べない」理由もここに出ます
log は単調なので、大小関係も一緒に運べます(a < b なら log a < log b)。
ところが 2ˣ mod p は巻き戻るので、大小の関係は完全に壊れます。
運ばれるのは「構造として保たれる演算」だけで、順序はその中に入っていない。
(教科書の「準同型定理」は、この写像で何が潰れて何が残るかを分類するための定理です。 暗号が使っているのは定理そのものではなく、準同型という性質のほう—— 「壁の向こうで計算ができる」という一点だけです。だから定理を覚えていなくても、ここは読めます。)
準同型(homomorphism)とは構造を保つ写像のこと。
暗号化を E とすると、E(a) ⊕ E(b) = E(a + b) のような性質が成り立つとき、
暗号文のまま計算ができる。
ここが肝: 保てるのは、その構造がもともと持っている演算だけ。 暗号が新しい操作を発明することはできない。すでにある演算を、暗号の壁の向こう側に そのまま持ち越すのが準同型だから。
平文が住んでいるのは 環(足し算と掛け算ができる世界)か 体(さらに割り算もできる世界)。 そして環の定義に書かれている演算は、加法と乗法のちょうど 2 つ。それだけ。
だから比較は「直接には」運べない: 「<」は環の公理に入っていないので、 準同型で保ちようがない。「+ と × だけ」は暗号の都合ではなく、代数の定義そのもの。 (欲しければビットに分解して + と × から組み立て直す。 Week 5 で扱う TFHE の Programmable Bootstrapping は、その組み立てを暗号側で肩代わりして 暗号文に任意の表引きを適用できるようにする技術——底では同じ 2 つの演算の上に立っている。)
「四則演算ができる」と「演算は 2 つ」は矛盾しません。 引き算と割り算は、足し算と掛け算の裏返しとして定義されるからです。
引き算は「逆元を足すこと」。 a − b の正体は a + (−b) で、
−b は「足すと 0 になる相手」(加法逆元)。
F₅ なら −2 = 3(2 + 3 = 5 ≡ 0)なので、
3 − 2 = 3 + 3 = 6 ≡ 1。
割り算は「逆数を掛けること」。 a ÷ b の正体は a × b⁻¹ で、
b⁻¹ は「掛けると 1 になる相手」(乗法逆元)。
F₅ なら 2⁻¹ = 3(2 × 3 = 6 ≡ 1)なので、
3 ÷ 2 = 3 × 3 = 9 ≡ 4。検算すると 4 × 2 = 8 ≡ 3 で戻ります。
体の定義そのもの: 「環(加法と乗法がある)であって、0 以外のすべての元に乗法逆元がある」。 四則のうち 2 つは、残り 2 つの逆演算として後から出てくるのであって、 演算が 4 つあるわけではありません。Week 0 のスライドで加法逆元・乗法逆元の表を作ったのは、 まさにこれを確認するためでした。
引き算はゲートを消費しません。 a − b は a + (−1)·b と書けて、
定数倍は無料だからです。今回の role − 2 にコストがかからないのはこれが理由。
割り算にはゲートがありません。 代わりに答えを prover に主張させて、掛け算 1 回で検算します。
y = 1/x を主張させ、回路が課すのは x · y − 1 = 0 の 1 本だけ。
おまけで 0 除算も塞がる: x = 0 のとき x·y − 1 = 0 を満たす
y は存在しません。「0 では割れない」が制約として自動的に表現されるわけです。
そして 0 に逆数を認めない理由も同じで、認めると 0·y = 1 と 0·y = 0 が衝突します。
平文 m を g^m の形で隠す方式なら、暗号文どうしを掛けると
g^a · g^b = g^(a+b)。平文の足し算になっている。
教科書的な RSA なら m₁^e · m₂^e = (m₁m₂)^e で、こちらは平文の掛け算。
何をしているのか: どちらも新しい操作を作ってはいない。 指数法則というすでにある構造をそのまま借りているだけ。 そして片方しか手に入らない。+ と × を任意の回数こなせる方式(FHE)は 2009 年の Gentry まで作れなかった——回数制限つきで両方持つ方式はそれ以前からあったが、 制限を外すのがそれほど難しかった。
有限体には、+ と × と両立する大小関係が存在しない。
仮に 0 < 1 と決めると、両辺に 1 を足して 1 < 2、
さらに足して……と続き、いつまでも 0 に戻れないはずだが、
有限体では p 回足すと 0 に戻ってしまう。順序と矛盾する。
実務での意味: 「x < 8」という順序は体に備わっていないので、そのままでは書けない。
書きたければ + と × から組み立て直す —— x をビットに分解して各ビットに b·b−b=0 を課し
Σ bᵢ2ⁱ = x で繋ぐか、範囲が狭いなら (x−0)(x−1)…(x−7) = 0 と積で書く。
range proof が高価なのはこれが理由で、今回の課題が「許可リスト方式」なのも、
それを避けた設計になっている。
秘密分散では、各人が持つシェアを足すだけで秘密の和のシェアになる(通信ゼロ・無料)。 掛け算もできるが、多項式の次数が倍になるので次数を戻すための通信が要る。
だから MPC のコストは乗算ゲートの数で測る。 加算は無料、乗算は有料、 それ以外の操作はそもそも定義されていない。ZK とは技術がまったく違うのに、 使える演算が同じ 2 つに落ち着くのは、根が同じ代数だから。
証明系は「全制約が満たされている」を「ある多項式が恒等的に 0」に翻訳し、 ランダムな 1 点で評価するだけで検査する。 次数 d の 0 でない多項式は零点を高々 d 個しか持てないので、 でたらめに選んだ点でたまたま 0 になる確率は無視できるほど小さい。
この「1 点で全体を代表できる」性質は多項式だけのもので、 多項式は + と × で作られる。だから制約は多項式でなければならない。 SNARK 系の検証がミリ秒で終わる理由は、ここまで遡る。
「4 つ持てば便利では」への答え
増やせないのではなく、増やしても何も増えない。そして本当に増やせるものは、増やすと壊れる。
① 引き算と割り算は独立していない。
a − b = a + (−b) で完全に書けるものを 4 つ目として数えても、表現力は 1 ミリも増えません。
数学が構造を「2 つの演算」で定義するのは、独立に必要な最小の組を取るからです。
② 本当に独立な 4 つ目(比較など)は、持てたら暗号が壊れる。 中身を見ずに大小が分かる道具があれば、二分探索で平文が割れます。 増やせないのは代数の都合ではなく、安全性の要請です。
③ 除算をゲートにすると、回路がむしろ壊れる。
x = 0 のとき答えが定義できないからです。回路は「全部の等式が同時に成り立つか」を問う世界なので、
入力によって答えが無い関数をそのまま置けません。
制約 x·y − 1 = 0 に翻訳するほうが素直で、0 除算も自動的に塞がります。
④ そして決定的なのがこれ —— 2 つ + 「主張させて検算する」は、4 つより強い。
逆数は x·y = 1、平方根は y·y = x、ソートは「並べ替えたものを主張させて、
置換であることと単調であることを検査」。どれも専用ゲートを持たずに、掛け算数本で片が付きます。
回路の設計思想は「演算を増やす」ではなく「難しい計算は prover にやらせ、安く検算する」——
前の節で見た「回路は計算しない、答え合わせをする」が、ここで効いてきます。
(この手が使えるのは、答えを主張してくれる prover がいる ZK だからです。 MPC と FHE には主張する人がいないので、逆数もソートも本当に計算する必要があり、ずっと高くつきます。)
言い換え — 比較が無いのは、暗号が弱いからではない。強いからだ
もし「箱の中身を見ずに大小を判定して、答えをそのまま返してくれる」道具があったとする。
その道具があれば、x > 100? x > 50? x > 75? …… と
二分探索するだけで、中身の値が完全に特定できてしまう。
つまりその道具の存在は「この暗号は破れている」と同じ意味になる。
比較がすり抜けてこないのは、すり抜けたら暗号として成立しないから。
では比較は永久に不可能かというと、そうではない。答えを暗号のまま返すなら問題ない (中身が漏れないので)。ただしそれは環の演算ではないので、 ビットに分解して + と × から組み立てるしかない。だから高くつく。 「+ と × しかない」の正体は、不可能ではなくこの値段のこと。
(順序をわざと漏らす暗号も存在する = order-preserving encryption。速い代わりに、 設計として大小関係が漏れる。何を諦めるかのトレードオフになっている。)
では、これで全部表現できるのか
できる。表現力は失われていない。
有限体の上では、どんな関数も必ず多項式で書ける(ラグランジュ補間)。 入力の全パターンに対して望む出力を通る多項式を、必ず 1 本作れるからだ。 比較も、ハッシュも、機械学習の推論も、原理的には + と × だけで書ける。
失われるのは表現力ではなくコスト。素直に補間すると次数が
p 級(p は 2²⁵⁴ 程度)になり、制約が天文学的な本数になって誰も検証できない。
だから回路設計とは、「書けるか」ではなく「安く書けるか」を考える仕事になる。
今回のメンバーシップ判定がまさにその実例。「role が {2,5,6} に入っているか」を
(x−2)(x−5)(x−6) と書けば次数 3 で済む。
許可リストが 3 個しかないから安い。仕様が回路に優しく作られているのは偶然ではない。
呼び名
比喩ではなく、構造が本当に論理回路と同じ形をしている。ただし決定的に違う点が 1 つある。
論理回路が AND / OR / NOT ゲートを線で繋いだ図であるように、算術回路は 加算ゲートと乗算ゲートを線で繋いだ図。 入力信号がゲートを通って中間信号になり、最後に出力信号になる。 ループのない有向グラフ(DAG)になる。
右は、今回の f_role · (role−2)(role−5)(role−6) をゲートに分解したもの。
乗算ゲートが 3 つ並び、各ゲートの出口に中間信号が生まれる。
教材の前半で「中間結果に名前を付ける」と言ったのは、この中間信号のこと。
そして 乗算ゲート 1 個が、制約 1 本になる。
t1 = (role−2)·(role−5) のような等式を 0 の形に直すと
t1 − (role−2)(role−5) = 0。掛け算は 1 回だけなので次数 2。
R1CS が「次数 2 の制約の集まり」と説明されるのは、これが正体で、
今回の DSL が次数 4 の式を許しているのは学習用の簡略化。
ゲートに分解すると
(role−2) ─┐
├─[ × ]──→ t1 制約: t1 − (role−2)(role−5) = 0
(role−5) ─┘
t1 ─┐
├─[ × ]──→ t2 制約: t2 − t1·(role−6) = 0
(role−6) ─┘
t2 ─┐
├─[ × ]──→ t3 制約: t3 − t2·f_role = 0
f_role ─┘
t3 = 0 ← これが最終的に課す条件
乗算ゲート 3 個 → 中間信号 3 本 → 制約 3 本。R1CS 系では加算(引き算)は
ゲートを消費しないので role−2 は無料で、コストは乗算の数で決まる
(MPC も同じ。ただし PLONK のように加算も行を消費する方式もある)。
論理回路と決定的に違うところ — 時間が無い
回路は「実行する」ものではなく「満たす」もの。順序も状態も存在しない。
プログラムは時間軸に沿って状態が変わる。1 行目のあとに 2 行目が動く。 回路にはそれが無く、全部の等式が同時に成り立っているかどうかだけが問われる。 制約を並べる順番を入れ替えても、まったく同じ回路。
だから cs.assert_zero(...) は「実行される命令」ではなく
「提出される条件」。8 本書いたら、8 本が同時に成り立つ witness があるかどうか、
という一枚の連立方程式になる。
この「時間が無い」性質が、コストに直結する
ループは全部展開される。 回数が入力によって変わるループは書けない。 最大回数まで必ず展開され、回路のサイズはその最大回数ぶん膨らむ。
配列アクセスは全要素を読む。 a[i] は「i 番目だけ取り出す」ことができない
(どこを読んだかが秘密を漏らすし、そもそも添字で分岐できない)。
全要素を読み、選択子を掛けて他を 0 で潰す。要素数に比例したコストがかかる。
そして回路の形は、入力によって変わってはいけない。
形が変われば形そのものが秘密を漏らすから。
—— 後半で出てくる採点器の test_structure_is_value_independent は、
この性質を守らせているテストだ。
お題
先に「なぜこの題材なのか」を書きます。適当に選ばれた練習問題ではありません。
資格は (role, clearance, region) の 3 つ組。それぞれ 0〜7 の整数で、
3 つとも許可リストに入っているときだけ「正当(authorized)」とし、出力 granted を 1 にする。
許可リストは公開・固定(tests/spec.py)。秘密なのは資格の方だ。
資格の候補は 8 × 8 × 8 = 512 通り、うち正当なのは 3 × 3 × 2 = 18 通り、
残り 494 通りは本来 1 つも通ってはいけない。
この 494 という数字を覚えておくと、下のラボの採点器が出す数字がそのまま 「どれだけ壊れているか」の目盛りになる。
nPr や nCr は出てきません。使うのは積の法則だけです。
3 つの枠が区別されていて、それぞれ独立に選べるから、単純に掛けます。
順列ではない理由: 順列は「同じ集合から取り出して並べる」話です。
ここは role・clearance・region という役割の違う 3 つの枠で、並べ替えという概念がありません。
(2, 3, 1) の数字を入れ替えた (3, 2, 1) は「別の並び」ではなく、
role = 3 という許可リスト外の別人です。
組み合わせでもない理由: nCr は順序を区別せずに選ぶ話です。
ここでは枠が区別されるので、role の 2 と region の 2 は別物として扱われます。
3・3・2 という数字は、許可リストの要素数がそのまま出てきたものです。
spec.py に ROLE_OK = (2, 5, 6) と固定で書いてあるから role は 3 通り、
REGION_OK = (1, 6) だから region は 2 通り。
全候補の 8 も同じで、CODE_MIN = 0/CODE_MAX = 7 と決まっているから 8 通りです。
数え上げができるのは、リストが固定で公開されているから。
許可リスト(公開)
ROLE_OK = {2, 5, 6} # 3 個
CLEARANCE_OK = {3, 4, 7} # 3 個
REGION_OK = {1, 6} # 2 個
authorized := role∈ROLE_OK かつ
clearance∈CLEARANCE_OK かつ
region∈REGION_OK
region だけ許可が 2 個しかない。この非対称が、あとで「どこを壊すと一番被害が大きいか」に効いてくる。
| role | clearance | region | 掛けると | |
|---|---|---|---|---|
| 資格の全候補 | 8 | 8 | 8 | 512 |
| 正当(authorized) | 3 | 3 | 2 | 18 |
| 不正(512 − 18) | — | — | — | 494 |
3 桁のダイヤル錠と同じ数え方。1 つ目が 3 通り、2 つ目が 3 通り、3 つ目が 2 通りなら、 全部で 3 × 3 × 2 通り。
実際に書き出してみる
# role を 2 に固定すると、残りは 3 × 2 = 6 通り。全部書ける (2, 3, 1) (2, 3, 6) (2, 4, 1) (2, 4, 6) (2, 7, 1) (2, 7, 6) # role は 2 / 5 / 6 の 3 通りあるので、6 × 3 = 18 通り role=2 → 6 通り role=5 → 6 通り ← (5,3,1) (5,3,6) (5,4,1) (5,4,6) (5,7,1) (5,7,6) role=6 → 6 通り 合計 18 通り # 不正になる例。どれか 1 つでも許可リストの外にあれば不正 (0, 3, 1) role が外 (2, 0, 1) clearance が外 (2, 3, 0) region が外 ← Part B で使う witness (0, 0, 0) 全部外
この同じ掛け算が、あとの「実測」の表の数字を全部説明します。 region のリンクを外すと region だけ 8 通りに広がるので 3 × 3 × 8 = 72、そこから正当な 18 を引いて 54。 role のリンクを外すと 8 × 3 × 2 = 48 から 18 を引いて 30。 被害の大きさは「自由になった枠の選択肢が何倍に増えたか」で決まります —— region は 2 → 8 で 4 倍、role は 3 → 8 で 2.7 倍。だから region を外したときが一番大きい。
部品
使う部品は 3 種類だけ。どれも「代入してみれば分かる」ものなので、実際に値を入れて確かめる。
先に —— この 3 つが何の代わりなのか
回路には bool 型も in 演算子もありません。だから自分で作ります。
部品① は「真偽値」の自作。 型が無いので「この信号は 0 か 1」は
自分で 1 行書かないと存在しません。ZK 回路でいちばん多く書く制約で、
選択子 s·a + (1−s)·b の s、Merkle パスの左右、
範囲証明のビット分解——全部この 1 行が土台になります。
部品② は「in 演算子」の自作。 集合に属することを掛け算だけで表します。
そして「私はこの集合の一員だ」はZK アプリの主役級の主張です
(年齢証明・ホワイトリスト・KYC・投票権・Merkle 包含証明)。
この積は、その主張を + と × の世界に翻訳する方法そのものです。
そして部品③ が心臓です。① は主張を入れる器、② は事実を表す式。 ③ はその 2 つを縛りつける鎖で、Part B で抜けているのがまさにこの 1 本です。
① だけあっても、フラグは値と無関係のまま。② だけあっても、主張と結びついていない。 ③ が「1 と主張するなら、リストに入っていろ」を強制して初めて意味が生まれます。
b · b − b = 0これは「b は 0 か 1」を意味します。1 行ずつ変形すれば出ます。
b · b − b = 0
b² − b = 0 b·b を b² と書いただけ
b(b − 1) = 0 共通の b でくくった(分配法則の逆)
ここでさっき証明した「零因子がない」を使います。
積が 0 なら、どちらかの因子が 0。つまり b = 0 か b − 1 = 0。
後者は b = 1 なので、答えは 0 か 1 の 2 つだけです。
右の表で確かめられます。b = 2 なら 4 − 2 = 2 で 0 になりません。b = 7 なら 49 − 7 = 42。 0 と 1 以外は必ず余りが出ます。
回路に「型」はないので、ブール値という概念はこの 1 行で自作する。 書き忘れれば、そのフラグは 0/1 以外の値も取れる、ただの数になる。
| b | b · b | b·b − b | 判定 |
|---|---|---|---|
| 0 | 0 | 0 | 満たす |
| 1 | 1 | 0 | 満たす |
| 2 | 4 | 2 | 違反 |
| 3 | 9 | 6 | 違反 |
| 7 | 49 | 42 | 違反 |
(x − a₁)(x − a₂)…許可リストの各要素を引いて、全部掛ける。 x がリストのどれかに一致するとき、その因子が 0 になり、積全体が 0 になる。 一致しなければ、どの因子も 0 でないので積も 0 にならない(零因子がないから)。
つまりこの積は「x ∈ 許可リスト」という条件を、掛け算だけで表した式になっている。
回路で in 演算子を自作するとこうなる、ということ。
| role | (role−2)(role−5)(role−6) | 結果 | 意味 |
|---|---|---|---|
| 2 | 0 · (−3) · (−4) | 0 | リスト内 |
| 5 | 3 · 0 · (−1) | 0 | リスト内 |
| 6 | 4 · 1 · 0 | 0 | リスト内 |
| 0 | (−2) · (−5) · (−6) | −60 | リスト外 |
| 3 | 1 · (−2) · (−3) | 6 | リスト外 |
部品③ リンク — ①と②を結ぶ 1 行が、回路の心臓
f · (x − a₁)(x − a₂)… = 0
これが「f = 1 を主張するなら、x は許可リストに入っていなければならない」を強制する。
零因子がないので、f ≠ 0 なら積の方が 0 でなければならず、x はリストのどれかに一致するしかない。
逆に f = 0 なら、この式は x が何であっても 0 になる。
フラグを下ろしている限り、x は自由ということ。これは仕様どおりで問題ない
—— 権限を主張していないのだから。
向きに注意 — 保証されるのは片道だけ
この 1 行が保証するのは f = 1 ⟹ x ∈ S の一方向だけで、
逆(x ∈ S ⟹ f = 1)は保証されない。正当な資格を持っている人が、自分の意思で
f = 0 と置いて「権限がない」ことにするのは自由なままだ。
それで困らない。健全性が要求しているのは 「granted = 1 を主張したときに、本当に権限があるか」だけで、 これは前向きの含意で足りる。逆向きは、正直な prover がどう値を選ぶかという witness 生成側の話にすぎない。
Part A
部品が揃ったので順に積む。各ステップの「なぜ」まで押さえると、次週以降そのまま使える型になる。
cs.input("role", role) のように、名前を決めて 3 本置く。
なぜ名前が固定なのか: 採点器は名前で信号を探す。ここが違うと、回路が正しくても評価されない。 現実でも、証明系は「どの信号が公開入力か」を名前や位置で識別する。
f_role, f_clearance, f_region。
「このフィールドは許可リストに入っている」という主張を表す信号。
なぜ補助信号が要るのか: 3 条件の AND を 1 本の巨大な式で書くと次数が跳ね上がり、 実際の証明系では扱えない。中間結果に名前を付けて次数を下げるのは回路設計の基本動作で、 Circom を書くときも同じことをする。
f · f − f = 0 を、3 つのフラグと granted に課す。
なぜ granted にも要るのか: granted は外に出る出力だから。
ビットに縛らなければ granted = 6 のような値も許され、
「1 かどうか」で判定する検証側が壊れる。
f_role · (role−2)(role−5)(role−6) = 0 を、3 フィールドすべてに。
落とすとどうなるか: そのフィールドはフラグと切り離され、 「リストに入っていないのにフラグ 1」が成立してしまう。 これが今回の Part B で突く穴そのもの。下のラボでスイッチを切れば実物が見られる。
granted − f_role · f_clearance · f_region = 0。
フラグがビットなら、この積は「3 つとも 1 のときだけ 1」になる。最後に cs.set_output(granted)。
これが健全性の要: granted = 1 を主張した瞬間、積が 1 になり、 3 つのフラグはどれも 0 でなくなる。すると 3 本のリンクが一斉に発動し、 3 フィールドすべてを許可リストに縛りにいく。ここまでが一本の論理の鎖で、 どの環が欠けても嘘の証明が通る。
if role in ROLE_OK: で制約を出し分けてはいけない。値は入力ごとに変わってよいが、
式の集合はどの資格でも完全に同じでなければならない。
なぜそんな制限が: 現実の回路は一度コンパイルされ、全員が同じものを使う。
形が秘密で変わるなら、形そのものが秘密を漏らすし、verifier は検証すべき式を固定できない。
採点器の test_structure_is_value_independent はこれを見ている。
完成した Part A(提出した solution.py の中核)
# (1) フラグはすべてビット cs.assert_zero(f_role * f_role - f_role) cs.assert_zero(f_clearance * f_clearance - f_clearance) cs.assert_zero(f_region * f_region - f_region) cs.assert_zero(granted * granted - granted) # (2) メンバーシップリンク(3 フィールドすべてに必要) cs.assert_zero(f_role * _membership_product(r, ROLE_OK)) cs.assert_zero(f_clearance * _membership_product(c, CLEARANCE_OK)) cs.assert_zero(f_region * _membership_product(g, REGION_OK)) # (3) granted は 3 フラグの AND cs.assert_zero(granted - f_role * f_clearance * f_region)
健全性の証明を、言葉で 3 行
① granted = 1 と主張 → 積 = 1 → 3 フラグはどれも 0 ではない
② フラグ ≠ 0 なら、リンクの残り(メンバーシップ積)が 0 でなければならない
③ 積が 0 ⟺ その値は許可リストのどれかに一致
よって「granted = 1 にできる」なら 3 フィールドとも許可リストの中。 これが健全性そのもの。 逆に、正当な資格ならフラグを全部 1 に置けて全式が 0 になる —— こちらが完全性。
2 つの正しさ
片方だけ満たす回路はいくらでも作れる。だから採点器も別々にテストしている。
| 完全性 completeness | 健全性 soundness | |
|---|---|---|
| 主張 | 正当な資格なら granted=1 にできる | 不正な資格では granted=1 にできない |
| 守る相手 | 正直な利用者 | システムそのもの |
| 壊れると | 正しい人が入れない(不便) | 誰でも入れる(事故) |
| 壊し方 | 制約を足しすぎる | 制約を書き忘れる(アンダー制約) |
| 採点器の見方 | 18 通りを実際に組んで確認 | 494 通りに対し反例を全探索 |
| 気づきやすさ | すぐ気づく(自分が困る) | 誰も困らないので気づかない |
最後の行がこの課題の主題。制約を書き忘れた回路は、正直に使っている限り完璧に正常動作する。 テストも通る。攻撃されて初めて分かる。だから「壊れているかを自分から探しにいく」道具(=採点器の全探索)が要る。
実測
8 本を 1 本ずつ外して採点器を回した結果。上のラボで同じことが再現できる。
| # | 外した制約 | 採点器の結果 | 通る不正資格 |
|---|---|---|---|
| 0 | f_role がビット | 破れず | 0 |
| 1 | f_clearance がビット | 破れず | 0 |
| 2 | f_region がビット | 破れず | 0 |
| 3 | granted がビット | 破れず | 0 |
| 4 | role のメンバーシップリンク | 破れる (0, 3, 1) | 30 |
| 5 | clearance のメンバーシップリンク | 破れる (2, 0, 1) | 30 |
| 6 | region のメンバーシップリンク | 破れる (2, 3, 0) | 54 |
| 7 | AND(granted = 3 フラグの積) | 破れる (0, 0, 0) | 494 |
数字の出どころ: region のリンクを外すと region が 8 通りすべて自由になり、 role と clearance は許可内に縛られたままなので 3 × 3 × 8 = 72、 うち正当な 18 を引いて 54。role のリンクを外した場合は 8 × 3 × 2 = 48 から 18 を引いて 30。 許可リストが狭いフィールドほど、リンクを失ったときの被害が大きい。
「反例なし」は「安全」ではない
ビット制約 4 本は、外しても採点器を破れない。それでも消してはいけない。
理屈のうえでも破れないのは事実で、granted = 1 を主張すれば積が 1 になり各フラグは 0 でなくなるので、 リンクは効き続ける。ここまでは正しい。
理由①: 「採点器が反例を出さなかった」は「安全である」の証明ではない。
本物の tests/solver.py は自分の説明文で「これは形式的な健全性証明ではない」と明言しており、
ブールフラグと線形構造を前提にした発見的な探索にすぎない。
(この回路では実測したところ、ビット制約を外しても採点器は諦めずに
正しく全ケースを棄却していた。つまり表の 0 は本物の 0。ただしそれはこの回路がたまたま健全だったからで、
一般には別の話。)
理由②: この回路は「granted = 1 を主張したとき」だけを検査対象にしているから成立している。 フラグを他の回路と共有したり、出力を別の式で使い回した瞬間、 「フラグは 0 か 1」という前提が要る。安全性が周囲の文脈に依存している状態は、それ自体が危うい。
理由③: 出力の値域は、回路が自分で保証すべきもの。
いまは AND 制約と 3 つのフラグのビット制約が結果的に granted を 0/1 に押し込んでいるが、
それは他の制約に頼っているだけで、granted 自身は何も主張していない。
依存先が 1 本消えれば前提も消える。
Part B
tests/challenge.py は同じアクセス制御の回路だが、制約が 1 本足りない。読んで、突く。
challenge.py には 7 本しか制約がない。自分が組んだ 8 本と突き合わせると、
f_region · (region−1)(region−6) = 0 だけが無い(コメントとして残っている)。
探し方のコツ: 「何が書いてあるか」ではなく「対称性が崩れている場所」を見る。 3 フィールドを同じ形で扱っているはずの回路で、1 つだけ扱いが違えば、そこが穴。
守っていたのは「f_region = 1 なら region ∈ {1, 6}」という含意。
無いということは、f_region は region の値と完全に無関係になれる。
残っている制約を確認する: f_region をビットに縛る制約と AND の制約は残っている。
だが、どちらも region の値そのものには一切触れていない。
誰も region を見ていない——これが攻撃可能である証拠。
challenge.honest_witness(2, 3, 1) で、正当な資格の割当を作る。
これで 7 本の信号名がすべて正しく揃う。
ハマりどころ: challenge.py のフラグ名は f_clearance であって
f_clear ではない。名前を 1 つでも間違えると、採点器は制約を評価する前に
「attack() の witness に足りない信号」として不合格にする
(DSL の _resolve を直接叩いた場合は KeyError)。
正直な割当から作れば、この事故は起きない。
region を許可リスト外の 0 に。f_region は 1 のまま。
granted は 1·1·1 = 1 のまま。role と clearance には触らない
—— こちらのリンクは生きているので、動かせば即座に違反になる。
成立の確認: ビット制約 4 本 ✓/role・clearance のリンク 2 本 ✓(値は正当なまま)/ AND ✓。7 本すべて 0 で、granted = 1、しかし資格 (2, 3, 0) は不正。 本来入れない人が入れた。
提出した attack() の中核
witness = challenge.honest_witness(2, 3, 1) witness["region"] = 0 # 許可リスト {1,6} の外 witness["f_region"] = 1 # 止める制約が無い witness["granted"] = 1 # 1·1·1 = 1 return witness
同じ witness を、自分の回路にぶつけると
challenge.py(制約 7 本)→ 全部 0。通る。
自分の build()(制約 8 本)→ #6 で 1 · (0−1) · (0−6) = 6。0 でないので拒否。
違いは、書いた式が 1 本多いこと。ただそれだけで、 同じ嘘が通る回路と通らない回路に分かれる。
分業
この 2 つを混同していると、「なぜ書き忘れが致命傷になるのか」がずっと腑に落ちない。
cs.aux("f_role", 1 if role in ROLE_OK else 0) には 2 つの役割が同居している。
第 2 引数は witness の値で prover の仕事、
cs.assert_zero(...) は 制約で verifier の仕事。
証拠に、フラグの値を 1 とベタ書きしても Week 1 のテストは 5 本とも通る。
完全性テストは正当な資格しか組まないし、健全性テストは値を捨てて制約だけから解き直すので、
採点器には区別がつかない。
つまり solution.py を書くとき、あなたは2 人分の仕事を同時にしている。
そして攻撃者が守ってくれるのは verifier 側だけ。prover 側のコードを攻撃者が
自分用に書き直すのは自由だ ——それが Part B でやったことそのもの。
だから、こう読み替える
witness の値 = 攻撃者が好きに書き換えられるもの
制約 = 攻撃者が絶対に避けて通れないもの
回路を書き終えたら、witness の値の部分を全部消して読み直す。 それでも仕様が守られているなら健全。守られないなら、そこに制約を足す。 これが今週いちばん持ち帰る価値のある習慣。
採点器が値を無視する理由
solver.py は role/clearance/region と granted=1 だけを固定し、
残りの信号を未知数として解き直す。
あなたが用意した witness の値は一切見ない。攻撃者は自分で witness を作るのだから、
それが正しいシミュレーションになる。
設計
暗号を選ぶのが先ではありません。要件を 4 つの問いで確定させてから、 それを満たすプロトコルを探しにいきます。順番を逆にすると、道具に合わせて問題を歪める羽目になります。
証明したり計算したりする主体は誰か。1 人か、複数人か、委託先か。
KelpDAO の例: off-chain の AI Auditor。
隠す対象と、隠す相手を両方書き出す。ここを「誰から」まで詰めないと、方式が決まりません。
KelpDAO の例: 発見した攻撃手順 W を、事業者・サードパーティ・他の利用者から隠す。 先に公開すると、見た人が先に攻撃できてしまうから。
隠したまま何を計算・主張するのか。これが回路になる部分です。
KelpDAO の例: 「W を入れるとこのコントラクトが壊れる」という計算。
結果を受け取って判断するのは誰か。人か、コントラクトか、別のサーバか。
KelpDAO の例: on-chain のコントラクトが検証し、通れば自動で遮断する。 検証者がコントラクトだからこそ、人の判断を待たずに止められる。
この 4 つが埋まって、はじめて方式が決まる
「相手が嘘をつくかもしれない」が問題なら ZK。「複数人の秘密を突き合わせたい」なら MPC。 「計算を外に出したい」なら FHE。4 つの問いの答えが、そのまま選択の根拠になります。
今週の課題を 4 つに当てはめると——
① prover が ② 資格 (role, clearance, region) を verifier から隠して
③「権限がある」ことを計算し ④ verifier が制約 8 本で検証する。
ただし Week 1 では ② の「隠す」部分だけを外してあります。 witness は丸見えのまま渡していて、隠すのは上に暗号を載せてからの話。 今週は ③ と ④ を正しく作ることに集中している、という位置づけです。
現場の道具
今週使った Python の DSL は学習用。実務では専用の言語やライブラリを使う。 どれを選んでも、今日のバグは消えない。
| 道具 | 書き方 | 位置づけ |
|---|---|---|
| Circom | 専用 DSL(signal / <== / ===) | R1CS を自分の手で組む。制約が見える |
| Noir | Rust 風の専用言語(Aztec 製) | Rust そのものではない。コンパイラが Rust 製で、構文が Rust に似ている |
| halo2 / arkworks | 本物の Rust(ライブラリ) | 回路を Rust のコードとして組み立てる |
| gnark | 本物の Go(ライブラリ) | 同上の Go 版 |
| zkVM(RISC Zero / SP1) | 普通の Rust プログラム | 回路を書かない。書いた Rust の実行を証明する ← Week 6 の zkvm-exploit |
「Noir は Rust で書ける」はほぼ当たり。ただし正確には Rust に似た独自言語で、 ACIR という中間表現に落ちるため証明系を差し替えられる。 本当に Rust そのものを書くのは halo2 / arkworks(回路をコードで組む)と zkVM(普通のプログラムを書く)。
Circom — 代入と制約が別の記号になっている
// <== は「代入 + 制約」 inter <== in1 * in2; // <-- は「代入だけ」。制約は作られない bit <-- (in1 >> 0) & 1; bit * (bit - 1) === 0; // ← 手で書く
<-- は値を入れるだけで、回路には何も約束させない。
公式ドキュメントの range proof の例ですら、<-- の直後に
=== 0 を手で書いている。この 1 行を忘れたら、今日の Part B と完全に同じ穴が空く。
ついでに公開/秘密の指定も Circom にはある: component main {public [in1, in2]} と
書いた入力だけが公開で、それ以外の入力は秘密。今週の aclib.py に
この区別が無いのは、学習用に秘匿性を外してあるから。
Noir — 「制約されていない」ことを言語が明示させる
// Safety: 下の assert で out を縛っている let out = unsafe { u72_to_u8(num) }; let mut reconstructed: u72 = 0; for i in 0..8 { reconstructed += (out[i] as u72 << (56 - 8*i)); } assert(num == reconstructed); // ← ここで初めて縛られる
unconstrained fn の戻り値は制約されていないので、
呼ぶ側が unsafe { } で囲み、// Safety: コメントで理由を書き、
自分で assert して縛ることをコンパイラが要求する。
これは今週の f_role とまったく同じ構造 ——
重い計算は prover にやらせ、回路は答え合わせだけする。
縛り忘れたらそのまま通る、というリスクの形も同じ。
つまり
言語が変わっても、「主張させて、縛り忘れる」というバグの形は変わらない。
Circom は <--、Noir は unconstrained、halo2 なら値を置いただけの cell。
どれも「値は入るが、回路は何も約束していない」状態を作れる。
今週 8 本の式で覚えた「この信号を縛っている式はどれか」という問いは、そのまま持ち運べる。
zkVM(Week 6)は回路を書かないのでこの形のバグは減るが、代わりに
何を public にして何を witness にするかの設計が残る。
zkvm-exploit が「guest プログラムと public/witness 設計」を課題にしているのは、そこが勝負どころだから。
用語