4 段階検証モデル
E2E 検証可能投票の 4 段階検証モデルの設計と各段階の保証を解説します。
各段階は独立した暗号的保証を提供し、いずれかが失敗した場合でも、どの段階で何が問題かを特定可能にします。
段階間の依存関係
概念モデルとしては 4 段階を順に評価しますが、実装では GET /api/verify がチェック群をまとめて計算します。STARK ステータスによって Counted チェックが pending / not_run / failed にゲートされる(zkGate)ため、後段の評価可否に影響します。また、UI は STARK 解決後に段階表示を順次進めます。
現行実装では、各段階のチェック判定そのものは主に GET /api/verify 側で計算され、クライアントは結果の表示とシーケンス制御を担当します。validateVotingIntegrity は補助判定として存在しますが、通常の /verify 応答では journal が省略されるため常時実行される経路ではありません。
flowchart LR
subgraph "証拠の生成"
V["投票時<br/>レシート発行"]
F["集計時<br/>zkVM 実行"]
end
subgraph "4 段階検証"
S1["Stage 1<br/>Cast-as-Intended"]
S2["Stage 2<br/>Recorded-as-Cast"]
S3["Stage 3<br/>Counted-as-Recorded"]
S4["Stage 4<br/>STARK Verification"]
end
V --> S1
V --> S2
F --> S3
F --> S4
S1 ~~~ S2
S2 ~~~ S3
S3 ~~~ S4
Stage 1: Cast-as-Intended
目的
投票者が意図した選択肢のコミットメントが、サーバーから返却されたレシートと一致することを確認します。これにより、サーバーが投票者の選択を差し替える攻撃を検出します。
検証する内容
現行実装では、GET /api/verify が投票時にセッションへ保存された 3 つの値(選挙 ID、選択肢、乱数)からコミットメントを再計算し、投票時のレシートに含まれるコミットメント値と照合します。暗号式自体は、投票者がローカルで再計算する Cast-as-Intended と同一です。
flowchart LR
subgraph "投票時に確定したデータ"
EID[選挙 ID]
CH[選択肢]
RND[乱数]
end
subgraph "再計算"
HASH["SHA-256<br/>(ドメインタグ || 選挙ID || 選択肢 || 乱数)"]
end
subgraph "照合"
CMP{"一致?"}
REC[レシートの<br/>コミットメント]
end
EID --> HASH
CH --> HASH
RND --> HASH
HASH --> CMP
REC --> CMP
必要な証拠
| 証拠 | 保管場所 | 説明 |
|---|---|---|
| 選挙 ID | サーバーセッション | セッション作成時に確定した UUID |
| 選択肢 | サーバーセッション | 投票時に保存された投票者の選択肢(A〜E) |
| 乱数 | サーバーセッション | 投票時にクライアントが生成し、投票データとして保存された 32 バイト乱数 |
| レシート | サーバーセッション | 投票受理時に返却されたレシート(commitment, voteId, bulletinIndex) |
失敗モード
| 症状 | 原因 | 深刻度 |
|---|---|---|
| セッション証拠の欠落 | セッション期限切れ・破損などで投票時データを復元できない | 検証不能 |
| コミットメント不一致 | 投票時データとレシートの不整合、またはエンコーディングの不整合 | 重大 |
| 選択肢の範囲外 | 不正な入力(A〜E の範囲外) | 重大 |
| 乱数フォーマット不正 | 32 バイト hex でない | 重大 |
限界
現行の /api/verify 経路では Cast-as-Intended はセッション保存済み証拠に依存します。セッション証拠を復元できない場合、この段階は not_run になり最終判定は Verified になりません。
Stage 2: Recorded-as-Cast
目的
投票者のコミットメントが、追記専用の掲示板(CT Merkle ツリー)に正しく記録されていることを確認します。さらに、掲示板が追記専用性を維持していること(過去のエントリが削除・改変されていないこと)を検証します。
検証する内容
この段階では 3 種類の検証を行います。
flowchart TB
subgraph "2a: 包含証明"
IP["包含証明の検証<br/>自分のコミットメントが<br/>ツリーに存在するか"]
end
subgraph "2b: 整合性証明"
CP["整合性証明の検証<br/>投票時のルートから<br/>最終ルートへの追記専用性"]
end
subgraph "2c: 第三者 STH 検証"
STH["STH 合意の検証<br/>独立したソース間で<br/>ツリー状態が一致するか"]
end
IP --> RESULT{"全て成功?"}
CP --> RESULT
STH --> RESULT
2a: 包含証明(Inclusion Proof)
RFC 6962 の PATH 関数に基づく CT スタイルの Merkle 包含証明を検証し、投票者のコミットメントが掲示板のツリーに含まれていることを確認します。検証者はリーフハッシュと監査パスからルートハッシュを再計算し、期待されるルートと照合します。
2b: 整合性証明(Consistency Proof)
投票時点のツリー状態(ルートとサイズ)から、最終的なツリー状態への遷移が追記のみで行われたことを検証します。RFC 6962 の SUBPROOF アルゴリズムに基づく整合性証明により、サーバーが過去のエントリを密かに削除したり順序を変更したりするスプリットビュー攻撃を検出します。
2c: 第三者 STH 検証(オプション)
複数の独立した STH(Signed Tree Head)ソースに問い合わせ、比較可能な応答間で合意が成立しているかを確認します。照合の必須項目は STH ダイジェストで、bulletinRoot と treeSize は各ソースが返した場合に追加照合されます。これにより、サーバーが検証者ごとに異なるツリー状態を提示するスプリットビュー攻撃を検出します。
必要な証拠
| 証拠 | 取得元 | 説明 |
|---|---|---|
| 包含証明の検証結果 | /api/verify | サーバー側で RFC 6962 包含証明を評価したチェック結果 |
| 整合性証明の検証結果 | /api/verify | サーバー側で RFC 6962 整合性証明を評価したチェック結果 |
| 投票時のルートハッシュ | レシート | 投票受理時のツリールート |
| 投票時のツリーサイズ(oldSize) | /api/verify の userVote.proof.treeSize または voteReceipt.bulletinIndex + 1 | 整合性証明の oldSize(投票時点の木サイズ) |
| 最終ルートハッシュ/最終ツリーサイズ | /api/verify | 集計時の最終状態 |
| 独立検証用の証明材料(任意) | /api/bulletin/:voteId/proof, /api/bulletin/consistency-proof | クライアント外で個別に包含証明/整合性証明を再検証するための材料 |
| STH スナップショット | 設定済み STH ソース(例: /api/sth + 外部) | 第三者ソースの照合対象(必須は digest、root/treeSize は返却時のみ) |
失敗モード
| 症状 | 原因 | 深刻度 |
|---|---|---|
| 包含証明の検証失敗 | ツリーサイズ/インデックスの不一致、掲示板のリセット | 重大 |
| 整合性証明の検証失敗 | 追記専用性の違反(スプリットビュー攻撃の可能性) | 重大 |
| 第三者 STH 合意の不成立 | サーバーが検証者ごとに異なるツリーを提示 | 重大(有効時) |
| ルートが履歴に存在しない | ルート履歴の不整合 | 重大 |
Stage 3: Counted-as-Recorded
目的
掲示板に記録された全投票が、zkVM の集計処理に正しく含まれたことを確認します。投票の除外、欠落、重複がないことを検証し、集計結果の完全性を保証します。
検証する内容
flowchart TD START["Stage 3 開始"] G1["counted_missing_indices_zero<br/>excludedCount == 0"] G2["counted_expected_vs_tree_size<br/>totalExpected == treeSize"] G3["counted_input_commitment_match<br/>公開入力とジャーナルが一致"] G4["counted_my_vote_included<br/>ビットマップ証明で自票を確認"] OTHERS["残り required チェック<br/>counted_input_sanity<br/>counted_unique_indices<br/>counted_unique_commitments<br/>counted_tally_consistent"] PASS["Counted-as-Recorded: success"] FAIL["Counted-as-Recorded: failed"] START --> G1 G1 -->|NG| FAIL G1 -->|OK| G2 G2 -->|NG| FAIL G2 -->|OK| G3 G3 -->|NG| FAIL G3 -->|OK| G4 G4 -->|NG| FAIL G4 -->|OK| OTHERS OTHERS -->|all success| PASS OTHERS -->|any failed| FAIL
flowchart TD
PUB["公開系チェック (4)<br/>counted_input_sanity<br/>counted_unique_indices<br/>counted_unique_commitments<br/>counted_input_commitment_match"]
ZK["zk 系チェック (4)<br/>counted_tally_consistent<br/>counted_missing_indices_zero<br/>counted_expected_vs_tree_size<br/>counted_my_vote_included"]
RESULT{"全 8 required チェックが success?"}
PASS["Stage 3 を通過"]
FAIL["Verified をブロック"]
PUB --> RESULT
ZK --> RESULT
RESULT -->|Yes| PASS
RESULT -->|No| FAIL
必要な証拠
| 証拠 | 取得元 | 説明 |
|---|---|---|
| zkVM ジャーナル(詳細) | /api/verify?includeJournal=1 | 集計結果、除外情報、入力コミットメントの詳細 |
| 集計サマリー(通常応答) | /api/verify | missingIndices / invalidIndices / excludedCount などの上位値 |
| 公開入力サマリー | /api/verify | zkVM に渡された入力の公開可能部分 |
| ビットマップ証明 | /api/bitmap-proof | 自分のインデックスが集計に含まれた証明 |
| ビットマップルート | ジャーナル | zkVM ゲストが計算したビットマップ Merkle ルート |
通常の UI フローでは includeJournal=1 を付けないため、Counted チェックは上位フィールドと publicInputSummary を主に使用します。
重要な判定: excludedCount
excludedCount(除外された投票の数)は、本システムにおいて最も重要な不変条件の一つです。
excludedCount == 0: 全投票が集計に含まれた(正常)excludedCount > 0: 一部の投票が集計から除外された(即座に検証失敗)
この値が 0 でない場合、counted_missing_indices_zero が failed になり、最終サマリー判定は「Verified」を表示しません。
失敗モード
| 症状 | 原因 | 深刻度 |
|---|---|---|
excludedCount > 0 | 投票が集計から除外された | 重大(即座にブロック) |
| 欠落インデックスの検出 | 一部の投票が zkVM 入力に含まれなかった | 重大 |
| 集計合計の不一致 | 選択肢ごとの合計が有効投票数と一致しない | 重大 |
| 入力コミットメント不一致 | 公開入力と zkVM 実行で使用された入力が異なる | 重大 |
| ビットマップ証明の欠落 | ビットマップデータが利用不可、またはルート不一致 | 警告 |
| ツリーサイズの不一致 | totalExpected と treeSize が異なる(暗黙の除外) | 重大(必須チェック失敗) |
Stage 4: STARK Verification
目的
zkVM の実行が正しく行われたことを STARK 証明(レシート)の暗号学的検証により確認します。レシートの検証に成功すれば、ジャーナルの内容(集計結果、除外情報、入力コミットメント等)がゲストプログラムの正しい実行の結果であることが保証されます。
検証する内容
flowchart LR
subgraph "入力"
RCP[レシート<br/>Seal + Journal]
EID[期待 Image ID]
end
subgraph "Rust 検証サービス"
VF["Receipt::verify(imageId)"]
end
subgraph "結果"
OK["success<br/>暗号学的に検証済み"]
NG["failed<br/>証明が無効"]
DM["dev_mode<br/>フェイクレシート"]
end
RCP --> VF
EID --> VF
VF --> OK
VF --> NG
VF --> DM
検証の 2 段階
STARK Verification では 2 つのチェックを実行します。
Image ID 照合: レシートに記録された Image ID が、デプロイ済みの期待 Image ID と一致するかを確認します。Image ID はゲストプログラムのバイナリから導出される暗号的識別子であり、ゲストプログラムが改変されていないことを保証します。
レシート検証: RISC Zero の Receipt::verify() を呼び出し、Seal(STARK 証明)がジャーナルと Image ID に対して暗号学的に正当であることを検証します。この検証は計算量が多いため、サーバー側の Rust 検証サービスで実行されます。
必要な証拠
| 証拠 | 取得元 | 説明 |
|---|---|---|
| レシート(Seal + Journal) | 証明バンドル | zkVM ホストが生成した STARK 証明 |
| 期待 Image ID | imageId-mapping.json | デプロイ済みゲストプログラムの暗号的識別子 |
開発モードの検出
RISC0_DEV_MODE=1 で生成されたレシートは InnerReceipt::Fake 型であり、暗号学的な保証を持ちません。検証サービスはこれを dev_mode ステータスとして報告します。チェック評価では、設定に応じて dev_mode は success または not_run に正規化されます(既定では not_run 側)。
失敗モード
| 症状 | 原因 | 深刻度 |
|---|---|---|
| Image ID 不一致 | マッピングが古い、またはプローバーイメージが異なる | 重大 |
| レシート検証失敗 | 証明が暗号学的に無効 | 重大 |
| 開発モード検出 | フェイクレシートが混入 | 重大(本番環境) |
段階ステータスの集約
各段階のステータスは、その段階に属するチェックの結果から導出されます。
| 集約ルール | 条件 |
|---|---|
failed | いずれかのチェックが failed |
running | failed がなく、いずれかのチェックが running |
pending | failed/running がなく、いずれかのチェックが pending |
success | 全チェックが success |
not_run | 上記のいずれにも該当しない |
最終的な「Verified」表示の判定は、4 段階の結果に加えて追加のゲーティング条件を適用します。詳細は ゲーティングロジック を参照してください。