ゼロ知識証明(Zero-Knowledge Proof)は、ある主張が正しいことをその主張の成立以外の知識を相手に与えずに確かめてもらうための技術です。例えば、秘密の入力を使った計算について、入力そのものを渡さずに、公開した結果が計算の条件を満たすことを示せます。
Smart Contractでは、ノードがコードを実行して結果を確かめました。入力を公開したくない場合や、別の場所で行った計算を検証したい場合には、証明を作って渡す方法が使えます。
%%{init: {"theme":"base","themeVariables":{"primaryColor":"#eaf1f5","primaryTextColor":"#28343b","primaryBorderColor":"#9cabb5","lineColor":"#596b76","textColor":"#28343b","actorBkg":"#eaf1f5","actorBorder":"#9cabb5","actorTextColor":"#28343b","signalColor":"#596b76","signalTextColor":"#28343b","noteBkgColor":"#f7f1e5","noteTextColor":"#403c32"}}}%%
flowchart TD
W["秘密の入力"] --> P["証明する側<br/>プログラムを実行して証明を生成"]
C["合意したプログラム"] --> P
P --> O["公開する結果と証明"]
O --> V["検証する側"]
C --> V
V --> R["条件を満たす実行結果か確認"]
本記事では、Rustで書いたプログラムの実行を証明できるRISC Zeroを使い、次の疑問を説明します。
- 秘密の入力を隠すことと、結果を検証することはどう両立するのか。
- 何を証明するかは、どこで決まるのか。
- RISC Zeroのguest、host、receiptは何を担当するのか。
- 公開した結果や、検証対象のプログラムを変えるとどうなるのか。
サンプルはローカルCPUで証明を生成します。チェーン上への提出や、証明方式の数式による導出は扱いません。
公開する主張と秘密の入力
まず、証明する内容を具体的にします。「391は、1より大きい2つの整数の積で表せる」という主張を考えます。391は公開し、2つの整数を秘密の入力にします。
この場合、証明する側(prover)は条件を満たす整数を知っています。検証する側(verifier)が知りたいのは、そのような整数の存在であって、必ずしも整数の値そのものではありません。条件を満たす根拠になる秘密のデータをwitnessと呼びます。
| 要素 | 今回の例 |
|---|---|
| 公開する主張 | 391を1より大きい2つの整数の積で表せる |
| witness | 積が391になる2つの整数 |
| 検査する条件 | 両方が1より大きく、整数の範囲で積が391になる |
| 検証者が受け取る情報 | 公開値391と証明 |
重要なのは、公開する主張から分かることまで隠す技術ではないという点です。391のように小さい数なら、検証者自身が試し割りをして因数を求められます。本記事の小さな数は処理を追うための教材で、因数を秘密に保つ用途の例ではありません。
また、秘密の入力を受け取って計算するだけでは、ゼロ知識証明にはなりません。結果が正しいと相手が信じる理由が必要です。RISC Zeroでは、指定したプログラムの実行と公開した結果を結び付ける暗号学的な証明を生成します。
証明に求める性質
ゼロ知識証明では、完全性・健全性・ゼロ知識性という3つの性質を区別します。
| 性質 | 確かめたいこと |
|---|---|
| 完全性 | 正しい主張を正しく証明すれば、検証が通る |
| 健全性 | 誤った主張を受理される証明にできない |
| ゼロ知識性 | 証明を見ることで、主張の成立を超える知識を得ない |
実際の暗号システムでは、攻撃者の計算能力や安全性の仮定、無視できるほど小さい失敗確率などを含めて性質を定義します。RISC Zeroが使うような計算能力を制限した安全性の議論では、厳密にはargumentという呼び方も使います。本記事では、APIや開発資料に合わせて「証明」と呼びます。
暗号化との違いもあります。暗号化は、情報を読める相手を鍵で制御します。ゼロ知識証明では、秘密を復号して見せる代わりに、条件を満たしていることを検証させます。公開結果まで暗号化されるわけではありません。
RISC Zeroの実行環境
RISC Zeroは、RISC-Vという命令セットに対応したプログラムの実行を証明するzkVM(zero-knowledge virtual machine)です。開発者はRustなどで条件を書き、対応する実行形式にコンパイルします。公式のシステム解説は、実行を記録し、その正しさの証明を生成する構成を説明しています。
アプリは、hostとguestに分かれます。hostは通常のコンピュータで動き、入力の準備や証明の生成を管理します。guestはzkVM内で実行するプログラムで、証明したい条件をここに書きます。
%%{init: {"theme":"base","themeVariables":{"primaryColor":"#eaf1f5","primaryTextColor":"#28343b","primaryBorderColor":"#9cabb5","lineColor":"#596b76","textColor":"#28343b","actorBkg":"#eaf1f5","actorBorder":"#9cabb5","actorTextColor":"#28343b","signalColor":"#596b76","signalTextColor":"#28343b","noteBkgColor":"#f7f1e5","noteTextColor":"#403c32"}}}%%
flowchart TD
subgraph H["host:通常のRustプログラム"]
I["入力を用意"]
P["proverを起動"]
end
I --> G
P --> G
subgraph G["guest:zkVM内のプログラム"]
A["秘密の入力を読む"] --> B["条件を検査して計算"]
B --> C["公開する値をcommit"]
end
G --> R["receipt<br/>journalと証明"]
R --> V["検証側<br/>期待するimage IDでverify"]
guestの実行から得られるreceiptは、公開出力を収めるjournalと、実行の正しさを検証するための証明を含みます。Ethereumの取引レシートと同じ単語ですが、役割もデータ形式も異なります。
journalに値を追加する操作が commit です。Gitのコミットでも、値を隠すための暗号学的コミットメントでもなく、公開出力に書く操作です。秘密にしたい値をここに書けば、その値は公開されます。
検証者は、どのプログラムについて証明されたかも確認します。その識別に使う値がimage IDです。RISC Zeroでは、プログラムの初期メモリイメージに基づいて求まります。証明を送った相手が指定したIDを無条件に採用せず、検証者自身が期待するプログラムのIDを使います。用語集には、image ID、journal、receiptの関係が整理されています。
証明する条件をguestに記述
サンプルでは、1より大きい2つの u32 整数を受け取り、積だけを公開します。u32 は32ビットの符号なし整数です。guestの全コードは次の通りです。
#![no_main]
#![no_std]
risc0_zkvm::guest::entry!(main);
fn main() {
let (a, b): (u32, u32) = risc0_zkvm::guest::env::read();
assert!(a > 1 && b > 1);
let product = a.checked_mul(b).expect("u32 overflow");
risc0_zkvm::guest::env::commit(&product);
}
env::read() でhostが渡した入力を読みます。入力値は、この操作だけではjournalに入りません。assert! で両方が1より大きいことを確かめ、checked_mul で積が u32 の範囲を超えないことも確認します。どちらかを満たさなければ、正常終了しません。
最後の commit が公開するのは積だけです。正しいimage IDを指定した通常のreceipt検証と、公開結果が391であることの確認を組み合わせると、「このプログラムの条件を満たし、391を出力した実行がある」という主張を確認できます。
%%{init: {"theme":"base","themeVariables":{"primaryColor":"#eaf1f5","primaryTextColor":"#28343b","primaryBorderColor":"#9cabb5","lineColor":"#596b76","textColor":"#28343b","actorBkg":"#eaf1f5","actorBorder":"#9cabb5","actorTextColor":"#28343b","signalColor":"#596b76","signalTextColor":"#28343b","noteBkgColor":"#f7f1e5","noteTextColor":"#403c32"}}}%%
flowchart TD
S["非公開の入力 a, b"] --> Q{"a > 1 かつ b > 1"}
Q -->|"満たす"| M["積を計算<br/>オーバーフローも検査"]
Q -->|"満たさない"| E["正常終了しない"]
M -->|"範囲を超える"| E
M -->|"範囲内"| J["journalへ積を公開"]
assert! を消してしまうと、1と391という自明な入力も受け入れます。checked_mul を使わずに整数の桁あふれを許すと、意図した整数の積とは違う条件を証明してしまう可能性があります。証明システムが保証するのは書いたプログラムの条件であり、開発者が意図した条件を補ってくれるわけではありません。
hostからの証明生成と検証
hostのコードは入力を用意し、ローカルのproverを呼び出します。以下は主要部分の抜粋です。FACTOR_GUEST_ELF はコンパイルしたguest、FACTOR_GUEST_ID は対応するimage IDで、ビルド時に生成します。
let env = ExecutorEnv::builder()
.write(&(17_u32, 23_u32)).unwrap()
.build().unwrap();
let info = LocalProver::new("local")
.prove_with_opts(env, FACTOR_GUEST_ELF, &ProverOpts::succinct()).unwrap();
let receipt = info.receipt;
receipt.verify(FACTOR_GUEST_ID).unwrap();
let product: u32 = receipt.journal.decode().unwrap();
assert_eq!(product, 391);
入力は説明用に17と23をコードに直接書いています。検証処理の引数には、この2つの値は入りません。検証するのはreceiptと、期待するプログラムのimage IDです。その後、journalから取得した積をアプリが求める値391と照合します。
verify が成功しただけでは、アプリが欲しかった出力かどうかまでは決まりません。同じプログラムで別の積を計算したreceiptも、プログラムの実行としては正しいからです。アプリ側で公開値を確認する処理が必要です。
本サンプルでは、実行区間の証明を再帰的にまとめたsuccinct receiptを生成します。再帰的にまとめる前の形式はcomposite receiptです。さらにGroth16という別の証明形式に変換する経路もありますが、今回はローカルでの生成と検証に絞ります。これらの種類は証明システムの構成図で確認できます。
公式のSecurity Modelでは、再帰証明を通す前は実行長の情報が漏れ、再帰証明を通すとこの漏れを解消すると説明しています。本サンプルでsuccinctを選ぶ理由はここにあります。同資料は、目標とする完全なゼロ知識性について数学的な証明をまだ提示していないことも明記しています。一般的なゼロ知識性の定義と、この実装について示されている保証範囲は区別する必要があります。
ローカルサンプルの実行
サンプルはRISC ZeroのRustクレートを3.0.6に固定しています。lab/README.txtにツールチェーンの準備と実行手順を記載し、依存関係の解決結果もCargo.lockに保存しています。
公式のインストール手順でRISC Zero用Rustツールチェーンを用意した後、リポジトリのルートから次を実行します。初回は依存関係の取得とコンパイルが入ります。
cargo run --release --locked \
--manifest-path content/notes/blockchain-systems/zero-knowledge-proof/lab/Cargo.toml \
-p zk-note
RISC Zeroには、証明生成を省略するdev-modeがあります。これは開発中の動作確認には便利ですが、暗号学的な証明を作ったことにはなりません。本サンプルは disable-dev-mode を有効にし、LocalProver で実際の証明を作ります。外部の証明生成サービスは使いません。
以下は、実際に実行した出力のうち、経過時間を除いた部分です。
receipt_kind=succinct; dev_mode=disabled
verify_expected_image=PASS; public_product=391
tampered_journal=REJECTED
wrong_image_id=REJECTED
Assertions: PASS
サンプルは、正常なreceiptの検証に加えて、次の2つの失敗も検査します。
- journalの先頭1バイトを変え、公開結果と証明が一致しなくなったreceiptを拒否すること。
- 期待するimage IDの1ビットを変え、元のIDと一致しない値で検証すると失敗すること。
%%{init: {"theme":"base","themeVariables":{"primaryColor":"#eaf1f5","primaryTextColor":"#28343b","primaryBorderColor":"#9cabb5","lineColor":"#596b76","textColor":"#28343b","actorBkg":"#eaf1f5","actorBorder":"#9cabb5","actorTextColor":"#28343b","signalColor":"#596b76","signalTextColor":"#28343b","noteBkgColor":"#f7f1e5","noteTextColor":"#403c32"}}}%%
flowchart TD
R["生成したreceipt"] --> OK["正しいimage ID<br/>元のjournal"]
R --> J["正しいimage ID<br/>journalを改変"]
R --> I["image IDを改変<br/>元のjournal"]
OK --> A["検証成功"]
J --> B["検証失敗"]
I --> C["検証失敗"]
この2つの試験は、出力とプログラムが証明に結び付いていることを確かめる動作確認です。試験が通っただけで、証明方式の健全性やゼロ知識性を数学的に証明したことにはなりません。
秘密にできる範囲と用途
入力をjournalに書かなくても、公開結果から入力が推測できる場合はあります。本サンプルの391はその例です。ゼロ知識性が守るのは、公開した主張から得られる情報に加えて、証明が秘密について余分な知識を与えないことです。
また、hostは入力を扱います。証明生成を他者に委託し、その相手に入力を渡せば、その相手から入力が隠れるわけではありません。デバッグログに秘密を書いた場合も同様です。サンプルで入力をコードに直接書くのは説明のためで、秘密の保管方法を示すものではありません。
ゼロ知識証明は、入力の出所の正しさも自動的には保証しません。例えば、自己申告した生年月日を使って年齢条件を証明しても、実際の年齢の証明にはなりません。信頼する発行者の署名付きデータを検査するなど、入力と現実を結び付ける条件もプログラムに含める必要があります。
ブロックチェーンで利用する場合は、チェーンの外で計算し、その結果と証明をチェーン上で検証する構成が考えられます。ただし、検証コスト、対応する証明形式、公開するデータ、同じ証明の再利用を許すかなどはアプリごとの設計です。今回のサンプルは、Ethereumにそのまま提出できるトランザクションを生成するものではありません。
計算の正しさを証明することと、入力を隠すことは関連しますが、用途としては分けて考えられます。公開データの計算を証明する場合もあり、「ZKを使うシステムだから取引内容も非公開」とは限りません。何を公開するか、どのコードを検証するかを先に決めることが、サンプルを実際のアプリに広げる時の出発点になります。