Advanced Cryptography Program 2026 / Week 1 自習ノート
Week 1 の課題 proof-of-exploit を、前提から順に理解するためのページ。
「そもそもなぜ回路で書くのか」から始めて、制約を 1 本ずつ数値で読み、
最後は実際に制約を外して回路を壊せる対話型のラボまで通します。
この週で掴むことを 1 行にすると——
回路には if が無く、あるのは「この式は 0 でなければならない」という条件だけ。
条件を 1 本書き忘れると、その信号は攻撃者の持ち物になる。
全体像
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(脆弱性の証明)になっている。
週の地図
| 週 | テーマ | 課題 |
|---|---|---|
| 0 | 導入 | スライドのみ |
| 1 | Programmable Cryptography の全体像/算術回路 | proof-of-exploit ← 今ここ |
| 2 | 準備中 | — |
| 3 | 楕円曲線暗号と Schnorr プロトコル | schnorr-from-scratch |
| 4 | 準備中 | — |
| 5 | TFHE(Programmable Bootstrapping) | tfhe-toy-python |
| 6 | zkVM / vFHE / co-SNARK の組み合わせ | co-snark-prove/zkvm-exploit |
Week 3 は有限体 → 楕円曲線 → シグマプロトコル → Fiat-Shamir を下から積み上げる構成。 Week 5 は暗号文のまま NAND を計算する。Week 6 でそれらを組み合わせて 1 つのスタックにする。
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 種類のバグを見る目を作るためにある。
核心
ここが一番よく分からないところ。先に結論から言うと——
まず誤解を外す
回路そのものは、プライバシーを守らない。
証拠に、今週作った回路は witness を 7 個まるごと検証側に渡している。 role も clearance も丸見えで、隠れているものは何もない。
プライバシーを作るのは、その上に乗る暗号のほう。 ただしその暗号は「等式が成り立つかを確かめる」ことしかできない。 だから回路の役割は、確かめたいことを全部「等式」に翻訳しておくこと—— 暗号をかけられる形に、計算をあらかじめ整えておく係だ。
値 x を、乱数 r と一緒に C(x) = g^x · h^r のような形に潰す
(コミットメント)。中身は見えない(r が隠す)が、
後から x を差し替えることもできない(拘束)。
この 2 つが同時に成り立つのが重要: 見えないだけなら、あとで嘘をつける。 変えられないだけなら、中身が漏れる。両方あって初めて「隠したまま約束する」ができる。
C(x) · C(y) = g^(x+y) · h^(r+r') = C(x+y)。
箱どうしを掛け合わせると、中身が足し算された箱が出てくる。
これは指数法則の副作用で、誰かが機能として足したわけではない。
ここでプライバシーが生まれる: 「x + y = z です」という主張は、
C(x)·C(y) と C(z) が同じものを指すかを見るだけで確かめられる。
一度も箱を開けていないのに、等式が正しいことだけが分かった。
箱の外から言えるのは「この箱とこの箱は同じ中身か」まで。 「x のほうが大きいか」は言えない(言えたら箱が壊れている——次の節で説明する)。
だから回路が要る: 「権限がある」という言いたいことを、 足し算と掛け算と等号だけで書き直す。その翻訳結果が算術回路。 回路は暗号を「かけられる形」を用意する工程であって、隠す仕事はしていない。
信号を 1 つずつ箱に入れるのではなく、witness 全体を多項式にまとめてコミットする。 そして「全制約が満たされている」を「ある多項式が恒等的に 0」に変換し、 検証者が選んだランダムな 1 点での値だけを開示させる。
なぜ漏れないのか: 1 点の値から元の多項式(=witness)は復元できないうえ、 あらかじめ乱数で撹乱してある。なぜ騙せないのか: 制約が 1 本でも破れていれば その多項式は 0 でなくなり、ランダムな点でたまたま 0 になる確率は無視できるほど小さいから。 「隠す」と「騙せない」が同時に立つ。
| 層 | 担当 | 今週やること |
|---|---|---|
| 算術回路 | 言いたいことを等式に翻訳する | これ(暗号は使わない) |
| コミットメント | 値を隠しつつ、後から変えられなくする | — |
| 証明系 | 全制約をまとめて 1 点で検査する | — |
下の 2 層があってもいちばん上が間違っていれば、「嘘の主張に対する完璧な証明」ができあがる。 Week 1 が土台と呼ばれるのはそういう意味。
三兄弟
いつも 3 つ並べて語られるので混ざりやすい。だが解いている問題はまったく別物で、 共通しているのは道具立てのほうだけ。まず言葉の意味から。
| ZK ゼロ知識証明 | MPC 秘密計算 | FHE 準同型暗号 | |
|---|---|---|---|
| 解く問題 | 本当に正しく計算したか | 自分の値を他人に見せたくない | 計算は任せたいが中身は渡したくない |
| 秘密を持つ人 | 1 人(prover) | 参加者それぞれ | 依頼者 1 人 |
| 計算する人 | prover 自身 | 参加者全員で分担 | 受託者(クラウド) |
| 相手に渡るもの | 証明(SNARK 系なら数百バイト) | 計算結果だけ | 暗号化された結果 |
| 相手が学ぶこと | 主張が正しいという事実のみ | 結果のみ(他人の入力は不明) | 何も学ばない |
| 典型例 | 残高を見せずに送金の正当性を示す | 各社の給与を明かさず平均だけ出す | 暗号化した医療データをクラウドで解析 |
ZK で使うと
資格を持っているのは本人。granted = 1 を満たす witness を知っていることの証明を作り、
サーバに送る。サーバは role も clearance も region も永遠に知らないまま、
アクセスを許可する。
MPC で使うと
人事部が role、セキュリティ部が clearance、拠点管理が region を持つ。
誰も他部署の値を知らないまま、3 者で判定を計算して granted を得る。
主張する人がいないので、証明は登場しない。
FHE で使うと
資格を暗号化してクラウドに送る。クラウドは暗号文のまま判定を計算し、
暗号化された granted を返す。復号できるのは依頼者だけ。
ここにも証明は無い。
だから、回路は 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」は起きない(整数と同じ感覚でよい)。
ここから何が言えるか: f · (何かの積) = 0 という制約を書いておけば、
f ≠ 0 だと分かった瞬間、積の方が 0 でなければならないと断言できる。
許可リストの強制は、まるごとこの性質の上に乗っている。
原理
制限があるから 2 つに絞っている、のではない。2 つしか存在しないから 2 つ、という話。
準同型(homomorphism)とは構造を保つ写像のこと。
暗号化を E とすると、E(a) ⊕ E(b) = E(a + b) のような性質が成り立つとき、
暗号文のまま計算ができる。
ここが肝: 保てるのは、その構造がもともと持っている演算だけ。 暗号が新しい操作を発明することはできない。すでにある演算を、暗号の壁の向こう側に そのまま持ち越すのが準同型だから。
平文が住んでいるのは 環(足し算と掛け算ができる世界)か 体(さらに割り算もできる世界)。 そして環の定義に書かれている演算は、加法と乗法のちょうど 2 つ。それだけ。
だから比較は「直接には」運べない: 「<」は環の公理に入っていないので、 準同型で保ちようがない。「+ と × だけ」は暗号の都合ではなく、代数の定義そのもの。 (欲しければビットに分解して + と × から組み立て直す。 Week 5 で扱う TFHE の Programmable Bootstrapping は、その組み立てを暗号側で肩代わりして 暗号文に任意の表引きを適用できるようにする技術——底では同じ 2 つの演算の上に立っている。)
平文 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 系の検証がミリ秒で終わる理由は、ここまで遡る。
言い換え — 比較が無いのは、暗号が弱いからではない。強いからだ
もし「箱の中身を見ずに大小を判定して、答えをそのまま返してくれる」道具があったとする。
その道具があれば、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 という数字を覚えておくと、下のラボの採点器が出す数字がそのまま 「どれだけ壊れているか」の目盛りになる。
許可リスト(公開)
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 個しかない。この非対称が、あとで「どこを壊すと一番被害が大きいか」に効いてくる。
部品
使う部品は 3 種類だけ。どれも「代入してみれば分かる」ものなので、実際に値を入れて確かめる。
b · b − b = 0
これは「b は 0 か 1」を意味する。理由は代入すれば見える。
b·b = b を満たす数は、この世界に 0 と 1 の 2 つしかない
(b(b−1) = 0 と書き直すと、零因子がないので b = 0 か b = 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 通りに対し反例を全探索 |
| 気づきやすさ | すぐ気づく(自分が困る) | 誰も困らないので気づかない |
最後の行がこの課題の主題。制約を書き忘れた回路は、正直に使っている限り完璧に正常動作する。 テストも通る。攻撃されて初めて分かる。だから「壊れているかを自分から探しにいく」道具(=採点器の全探索)が要る。
ラボ
左のパネルで 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
ただし結論を急がない: 「反例が出ない」と「安全である」は別のこと。 理由は次の節で。
実測
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 を作るのだから、
それが正しいシミュレーションになる。
現場の道具
今週使った 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 設計」を課題にしているのは、そこが勝負どころだから。
用語
理解度チェック
読んだだけでは定着しない。ここではあなたが検証者の席に座り、 提出された 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 本でもあれば、そこが次の穴になる。